commit 2c99305fb0dbe40aa3cd722e077bb6086b2f3cb7 Author: Gabrieal Jimmy Date: Fri Oct 2 12:42:00 2026 -0500 init diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..65324d6 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# Copy to .env and fill in. Used by tools/pixellab.py (never committed). +PIXELLAB_API_KEY= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..46526f6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +*.png binary +*.jpg binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..14240d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Secrets +.env + +# Build output +build/ +build-*/ +cmake-build-*/ +out/ +compile_commands.json + +# Runtime output +screenshots/ + +# Editors / tooling +.cache/ +.vscode/ +.idea/ +*.swp +*~ +.DS_Store +__pycache__/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..35221df --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "blender": { + "command": "uvx", + "args": ["blender-mcp"] + } + } +} diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..b993305 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,77 @@ +cmake_minimum_required(VERSION 3.20) +project(hd2d LANGUAGES C CXX) + +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) +endif() + +include(FetchContent) + +# --- Dependencies ----------------------------------------------------------- +# 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) +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) +endif() + +find_path(STB_INCLUDE_DIR stb_image.h PATH_SUFFIXES stb) +if(NOT STB_INCLUDE_DIR) + FetchContent_Declare(stb GIT_REPOSITORY https://github.com/nothings/stb.git GIT_TAG master) + FetchContent_MakeAvailable(stb) + set(STB_INCLUDE_DIR ${stb_SOURCE_DIR}) +endif() + +find_path(CGLTF_INCLUDE_DIR cgltf.h) +if(NOT CGLTF_INCLUDE_DIR) + FetchContent_Declare(cgltf GIT_REPOSITORY https://github.com/jkuhlmann/cgltf.git GIT_TAG v1.14) + FetchContent_MakeAvailable(cgltf) + 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) + +# --- Engine ----------------------------------------------------------------- +file(GLOB_RECURSE HD2D_ENGINE_SOURCES CONFIGURE_DEPENDS engine/src/*.cpp) +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) +if(MSVC) + target_compile_options(hd2d PRIVATE /W4) +else() + target_compile_options(hd2d PRIVATE -Wall -Wextra -Wno-unused-parameter) +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") diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d2c377 --- /dev/null +++ b/README.md @@ -0,0 +1,227 @@ +# HD-2D Engine + +A small C++20 / OpenGL 4.5 engine that recreates the look of Square Enix's +**HD-2D** style (Octopath Traveler, Triangle Strategy, Live A Live, Dragon +Quest III HD-2D): a 3D diorama world where the ground is skinned in pixel art, +characters and small props (flowers, grass tufts, tall grass) are billboarded +pixel-art sprites, and scenery like trees, bushes and rocks are real **3D +models with higher-resolution painted textures**. Lighting, shadows, ambient +occlusion, fog, god rays, water, fire, rain and particles are all rendered as +high-resolution 3D effects. + +![demo](docs/screenshot.png) +![heavy rain](docs/rain.png) + +## What makes HD-2D look like HD-2D + +| Trait | How this engine does it | +| --- | --- | +| 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 | +| 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 | Scenery between the camera and the player is dithered away around the sprite | +| Sprites cast/receive real shadows | Shadow pass renders sprites as **sun-facing** cards so silhouettes are never edge-on; receivers sample their own sun-facing card to avoid self-shadowing | +| Miniature / diorama feel | Narrow FOV (~30°) high camera + strong depth-of-field with an extra tilt-shift band | +| Soft, warm, glowing light | HDR pipeline, Karis-averaged mip-chain bloom, ACES tonemap, lift/gain grading, vignette, subtle chromatic aberration and grain | +| Fog and god rays | Half-res ray-marched height fog; every step samples the sun shadow map and a sparse "canopy" beam pattern, giving physically shadowed light shafts; depth-aware upsample | +| Dappled forest light | Animated canopy cookie projected along the sun direction onto every surface | +| Beautiful water | Planar reflections, depth-aware refraction, per-channel absorption, analytic-derivative wave normals, shore distance field driving foam + ripples, strong HDR sun glints plus a field of twinkling point sparkles that bloom and depth of field turn into bokeh, caustics on the bottom | +| 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`): noise-eroded additive flame tongues with a hot-core-to-red color ramp, 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 | + +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 Unreal Fest Europe 2019 talk ["The Fusion of Nostalgia and Novelty"](https://www.unrealengine.com/events/unreal-fest-europe-2019/the-fusion-of-nostalgia-and-novelty-in-the-development-of-octopath-traveler), +and [The Verge on HD-2D's evolution](https://www.theverge.com/entertainment/840080/square-enix-hd2d-games-octopath-dragon-quest). + +## Building + +Requirements: CMake ≥ 3.20, 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. + +```sh +# Arch / CachyOS +sudo pacman -S cmake ninja glfw glm libepoxy stb + +cmake -S . -B build -G Ninja +ninja -C build +./build/hd2d_demo +``` + +The asset path is baked in at configure time (`HD2D_ASSET_DIR`), so the demo can +be run from any working directory. + +## Demo controls + +| Input | Action | +| --- | --- | +| WASD / arrows | Move the sprite (camera-relative) | +| Q / E, right-drag | Rotate / orbit camera | +| 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) | +| F5 | Hot-reload shaders | +| F12 | Screenshot to `screenshots/` | +| Esc | Quit | + +Scriptable captures (useful for look-dev and regression checks): + +```sh +./build/hd2d_demo --screenshot out.png --frames 60 --pos 25,43.6 \ + --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: +`volumetrics dof bloom reflections occluderFade ssao groundAO ssaoRadius +ssaoIntensity fogDensity fogHeightFalloff godRays shaftStrength shaftCoverage +shaftScale cookieStrength exposure bloomStrength tiltShift focusRange`. + +## Layout + +``` +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/sprites/ character sprites +assets/models/ scenery.glb (trees, bushes, rocks, bridge) + src/ painted textures +demo/ meadow demo scene + main loop +tools/pixellab.py pixel-art generator, driven by tools/assets.json +tools/scenery_textures.py paints the high-res scenery textures +tools/blender_scenery.py builds + exports the 3D scenery in Blender +``` + +### Frame pipeline (`Renderer::render`) + +```mermaid +flowchart LR + A[Sun shadow map
terrain, 3D models, sun-facing sprite cards] --> B[Planar reflection
half res, clipped] + B --> C[HDR scene
sky, terrain, models, billboards] + C --> C2[SSAO
half res, multiplied in] + C2 --> D[Water
reads color/depth copies] + D --> E[Volumetric fog + god rays
half res ray march, fire haze] + E --> F[Fog composite
depth-aware upsample] + F --> F2[Rain streaks] + F2 --> G[Particles
fire, smoke, fireflies] + G --> H[Depth of field
tilt-shift] + H --> I[Bloom mip chain] + I --> J[Tonemap + grade] +``` + +All per-frame data lives in one std140 uniform block (`FrameUBO` in +`Renderer.cpp` ⇄ `Frame` in `common.glsl`); keep them in sync. + +### Using the engine in your own project + +```cpp +hd2d::Window window; window.create(1600, 900, "My Game"); +hd2d::Renderer renderer; renderer.init("assets/shaders", window.width(), window.height()); + +hd2d::Terrain terrain; /* init, setLevel/setMaterial, textures.load(...), build() */ +hd2d::BillboardBatch hero; hero.loadTexture("assets/sprites/hero.png"); +hero.instances.push_back({{10, 0, 10}, 0.0f, hero.sizeForDensity(32.0f)}); +hero.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}); +oaks.upload(); + +hd2d::Scene scene; +scene.terrain = &terrain; +scene.billboards = {&hero}; +scene.models = {&oaks}; + +hd2d::GroundOcclusion contactAO; // dark contact shadow at tree bases +oaks.groundOcclusion = 1.0f; +contactAO.build({0, 0}, {64, 64}, {&oaks}); +scene.groundOcclusion = &contactAO; + +hd2d::ParticleSystem particles; // fire: flames, embers, smoke, glow, light +particles.addFire({12, 1.5f, 12}); +scene.particles = &particles; +scene.volumetricLights = 1; // the fire light also glows in the fog + +hd2d::Weather weather; // weather.setRain(1.0f) for a storm +hd2d::Camera cam; cam.target = {10, 0.7f, 10}; + +float time = 0.0f; +while (window.pollEvents()) { + time += 1.0f / 60.0f; + particles.update(1.0f / 60.0f, time); + scene.lights.clear(); + particles.collectLights(scene.lights, 12); + weather.update(1.0f / 60.0f, scene, renderer.settings); + renderer.render(scene, cam, time); + window.swapBuffers(); +} +``` + +`demo/DemoScene.cpp` is the full reference: terrain generation, prop +scattering, bridge walking, fires from model sockets, contact AO, water + +shore field, particles, weather, player movement and collision. + +## Assets + +### 3D scenery (Blender) + +Trees, bushes, rocks and the stone bridge (with braziers) are generated +procedurally so they can be tweaked and rebuilt at any time: + +```sh +python3 tools/scenery_textures.py # paints assets/models/src/*.png (numpy + Pillow) +blender -b -P tools/blender_scenery.py # builds the models, writes assets/models/scenery.glb +``` + +`blender_scenery.py` can also be run inside a live Blender session (e.g. through +the Blender MCP) with `exec(open(".../tools/blender_scenery.py").read())`; it +only touches its own `HD2D_Scenery` collection and lays the models out side by +side for inspection. Any glTF works with `ModelLibrary`; the engine relies on +a few conventions: + +- every root node with a mesh becomes a model, looked up by node name; +- `COLOR_0.rgb` multiplies the base color (tint / baked AO), `COLOR_0.a` is the + wind weight (0 rigid, 1 fully swaying); +- materials named `foliage*` are alpha-tested, two-sided and get wrap lighting, + translucency and soft self-shadowing; others honor glTF `alphaMode` and + `doubleSided`; +- foliage looks best with custom normals pointing away from the canopy center; +- emissive materials (glTF `emissiveFactor` / `emissiveTexture` / + `KHR_materials_emissive_strength`) glow and bloom; +- child empties of a model node become named **sockets** (`Model::sockets`, + model space; `ModelInstance::toWorld` places them). The bridge exports + `fire_0` / `fire_1` in its braziers and the demo spawns fires there. + +### Pixel art (PixelLab) + +`tools/pixellab.py` generates pixel-art textures through the +[PixelLab API](https://api.pixellab.ai/v2/llms.txt). Put your key in `.env` +(see `.env.example`); assets are declared in `tools/assets.json`. + +```sh +python3 tools/pixellab.py --list # what exists +python3 tools/pixellab.py # generate missing assets +python3 tools/pixellab.py tall_grass # regenerate one asset +python3 tools/pixellab.py --balance +``` + +- `"kind": "image"` uses `/create-image-pixflux` (transparent sprites). +- `"kind": "tiles"` uses `/create-tiles-pro` (seamless top-down tiles); every + variation is saved as `_.png`. A `"tile_id"` re-downloads an existing + set without paying again. + +Pixel-art texel density is 32 px per world unit; the 256 px test sprite is 4× +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 +entity/component layer for gameplay. diff --git a/assets/models/scenery.glb b/assets/models/scenery.glb new file mode 100644 index 0000000..3592a61 Binary files /dev/null and b/assets/models/scenery.glb differ diff --git a/assets/models/src/bark.png b/assets/models/src/bark.png new file mode 100644 index 0000000..be4fc53 Binary files /dev/null and b/assets/models/src/bark.png differ diff --git a/assets/models/src/coals.png b/assets/models/src/coals.png new file mode 100644 index 0000000..f14a926 Binary files /dev/null and b/assets/models/src/coals.png differ diff --git a/assets/models/src/cobble.png b/assets/models/src/cobble.png new file mode 100644 index 0000000..8d18158 Binary files /dev/null and b/assets/models/src/cobble.png differ diff --git a/assets/models/src/iron.png b/assets/models/src/iron.png new file mode 100644 index 0000000..3d0eed6 Binary files /dev/null and b/assets/models/src/iron.png differ diff --git a/assets/models/src/leaves_bush.png b/assets/models/src/leaves_bush.png new file mode 100644 index 0000000..f46cae6 Binary files /dev/null and b/assets/models/src/leaves_bush.png differ diff --git a/assets/models/src/leaves_dense.png b/assets/models/src/leaves_dense.png new file mode 100644 index 0000000..1c77d4a Binary files /dev/null and b/assets/models/src/leaves_dense.png differ diff --git a/assets/models/src/leaves_oak.png b/assets/models/src/leaves_oak.png new file mode 100644 index 0000000..a021f02 Binary files /dev/null and b/assets/models/src/leaves_oak.png differ diff --git a/assets/models/src/pine_frond.png b/assets/models/src/pine_frond.png new file mode 100644 index 0000000..eb2a8fb Binary files /dev/null and b/assets/models/src/pine_frond.png differ diff --git a/assets/models/src/rock.png b/assets/models/src/rock.png new file mode 100644 index 0000000..e130ded Binary files /dev/null and b/assets/models/src/rock.png differ diff --git a/assets/models/src/stone_blocks.png b/assets/models/src/stone_blocks.png new file mode 100644 index 0000000..0098e06 Binary files /dev/null and b/assets/models/src/stone_blocks.png differ diff --git a/assets/shaders/billboard.frag b/assets/shaders/billboard.frag new file mode 100644 index 0000000..f7c18d3 --- /dev/null +++ b/assets/shaders/billboard.frag @@ -0,0 +1,52 @@ +#version 450 core +#include "common.glsl" +#include "pixelart.glsl" + +in VS { + vec2 uv; + vec3 worldPos; + vec3 pivot; + vec2 local; + float tint; +} v; + +layout(binding = 0) uniform sampler2D uTex; +uniform int uPass; +uniform float uEmissive; + +layout(location = 0) out vec4 fragColor; + +void main() { + vec4 c = sharpSample(uTex, v.uv); + if (c.a < 0.5) discard; + if (uPass == 1) return; + + vec3 up = vec3(0.0, 1.0, 0.0); + vec3 toCam = cross(uBillboardRight.xyz, up); + // Sprites are flat cards; a normal leaning upward keeps them readable + // under top lighting while still responding to light direction. + vec3 n = normalize(toCam * 0.7 + up * 0.7); + vec3 L = uSunDir.xyz; + float ndl = clamp((dot(n, L) + 0.55) / 1.55, 0.0, 1.0); + + // Receive shadows at the matching point on this sprite's own sun-facing + // shadow card, nudged toward the sun: no self-shadowing, correct + // occlusion by everything else. + vec3 sp = v.pivot + uShadowRight.xyz * v.local.x + vec3(0.0, v.local.y, 0.0) + L * 0.22; + float vis = sunShadow(sp) * canopyCookie(v.worldPos); + + vec3 albedo = c.rgb * v.tint * mix(1.0, 0.8, uWeather.y); // rain-soaked sprites read darker + float gao = groundAO(v.pivot, v.local.y); + vec3 light = uSunColor.rgb * uSunDir.w * ndl * vis * mix(1.0, gao, 0.7) + hemiAmbient(n) * gao + + pointLights(v.worldPos, n); + // Thin-foliage translucency when the sun is behind the sprite. + float behind = max(dot(-toCam, L), 0.0); + light += uSunColor.rgb * uSunDir.w * behind * behind * vis * 0.25; + + vec3 col = albedo * light + albedo * uEmissive; + if (uBlob.w > 0.0) { // fold the player's contact shadow onto grass sprites too + 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)); + } + fragColor = vec4(col, 1.0); +} diff --git a/assets/shaders/billboard.vert b/assets/shaders/billboard.vert new file mode 100644 index 0000000..fdf6a87 --- /dev/null +++ b/assets/shaders/billboard.vert @@ -0,0 +1,58 @@ +#version 450 core +#include "common.glsl" + +layout(location = 0) in vec4 iPosSway; // pivot xyz, sway +layout(location = 1) in vec4 iSizeFlipTint; // size xy, flip, tint + +uniform vec4 uOpaque; // opaque pixel bounds in uv (minX, minY, maxX, maxY) +uniform int uPass; // 0 = color, 1 = shadow + +out VS { + vec2 uv; + vec3 worldPos; + vec3 pivot; + vec2 local; // (horizontal offset, height above pivot) in world units + float tint; +} o; + +void main() { + vec2 corner = vec2(float(gl_VertexID & 1), float(gl_VertexID >> 1)); + vec2 size = iSizeFlipTint.xy; + vec3 pivot = iPosSway.xyz; + float lx = (corner.x - 0.5) * size.x; + float ly = (corner.y - uOpaque.y) * size.y; // lowest opaque 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; + 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); + float k = clamp(ly / visibleHeight, 0.0, 1.0); + k *= k; + float ph = dot(pivot.xz, vec2(0.37, 0.61)); + vec2 wdir = normalize(uWind.xy + 1e-5); + float gust = vnoise(pivot.xz * 0.15 - wdir * uTime * uWind.w); + float w = (sin(uTime * 2.1 + ph) * 0.5 + sin(uTime * 3.7 + ph * 1.7) * 0.25 + gust * 1.3) * uWind.z; + vec2 disp = wdir * w; + // Bend away from the interactor (player) and rustle while it is close. + vec3 toP = pivot - uInteractor.xyz; + float d = length(toP.xz); + float push = clamp(1.0 - d / uInteractor.w, 0.0, 1.0) * step(abs(toP.y), 1.0); + disp += (d > 1e-3 ? toP.xz / d : vec2(0.0)) * push * 0.5; + disp += vec2(sin(uTime * 19.0 + ph * 7.0), cos(uTime * 17.0 + ph * 5.0)) * push * 0.05; + wp.xz += disp * k * sway * visibleHeight; + 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; + o.worldPos = wp; + o.pivot = pivot; + o.local = vec2(lx, ly); + o.tint = iSizeFlipTint.w; + gl_Position = uViewProj * vec4(wp, 1.0); + gl_ClipDistance[0] = dot(vec4(wp, 1.0), uClipPlane); +} diff --git a/assets/shaders/bloom_down.frag b/assets/shaders/bloom_down.frag new file mode 100644 index 0000000..04c2d1b --- /dev/null +++ b/assets/shaders/bloom_down.frag @@ -0,0 +1,40 @@ +#version 450 core +// 13-tap downsample (Jimenez, "Next Generation Post Processing in CoD:AW"). +// The first pass uses a Karis average to suppress fireflies. + +in vec2 vUV; +layout(binding = 0) uniform sampler2D uSrc; +uniform vec2 uSrcTexel; +uniform int uKaris; +layout(location = 0) out vec4 fragColor; + +float luma(vec3 c) { return dot(c, vec3(0.2126, 0.7152, 0.0722)); } +vec3 karis(vec3 c) { return c / (1.0 + luma(c)); } + +void main() { + vec2 t = uSrcTexel; + vec3 a = texture(uSrc, vUV + t * vec2(-2, 2)).rgb; + vec3 b = texture(uSrc, vUV + t * vec2(0, 2)).rgb; + vec3 c = texture(uSrc, vUV + t * vec2(2, 2)).rgb; + vec3 d = texture(uSrc, vUV + t * vec2(-2, 0)).rgb; + vec3 e = texture(uSrc, vUV).rgb; + vec3 f = texture(uSrc, vUV + t * vec2(2, 0)).rgb; + vec3 g = texture(uSrc, vUV + t * vec2(-2, -2)).rgb; + vec3 h = texture(uSrc, vUV + t * vec2(0, -2)).rgb; + vec3 i = texture(uSrc, vUV + t * vec2(2, -2)).rgb; + vec3 j = texture(uSrc, vUV + t * vec2(-1, 1)).rgb; + vec3 k = texture(uSrc, vUV + t * vec2(1, 1)).rgb; + vec3 l = texture(uSrc, vUV + t * vec2(-1, -1)).rgb; + vec3 m = texture(uSrc, vUV + t * vec2(1, -1)).rgb; + vec3 r; + if (uKaris == 1) { + vec3 g0 = karis((a + b + d + e) * 0.25), g1 = karis((b + c + e + f) * 0.25); + vec3 g2 = karis((d + e + g + h) * 0.25), g3 = karis((e + f + h + i) * 0.25); + vec3 g4 = karis((j + k + l + m) * 0.25); + r = g4 * 0.5 + (g0 + g1 + g2 + g3) * 0.125; + r /= max(1.0 - luma(r), 1e-3); // undo tonemap-weighting on the average + } else { + r = e * 0.125 + (a + c + g + i) * 0.03125 + (b + d + f + h) * 0.0625 + (j + k + l + m) * 0.125; + } + fragColor = vec4(max(r, vec3(0.0)), 1.0); +} diff --git a/assets/shaders/bloom_up.frag b/assets/shaders/bloom_up.frag new file mode 100644 index 0000000..7657655 --- /dev/null +++ b/assets/shaders/bloom_up.frag @@ -0,0 +1,18 @@ +#version 450 core +// 3x3 tent upsample, additively blended into the next larger mip. + +in vec2 vUV; +layout(binding = 0) uniform sampler2D uSrc; +uniform vec2 uSrcTexel; +uniform float uRadius; +layout(location = 0) out vec4 fragColor; + +void main() { + vec2 t = uSrcTexel * uRadius; + vec3 s = texture(uSrc, vUV).rgb * 4.0; + s += (texture(uSrc, vUV + vec2(-t.x, 0)).rgb + texture(uSrc, vUV + vec2(t.x, 0)).rgb + + texture(uSrc, vUV + vec2(0, -t.y)).rgb + texture(uSrc, vUV + vec2(0, t.y)).rgb) * 2.0; + s += texture(uSrc, vUV + vec2(-t.x, -t.y)).rgb + texture(uSrc, vUV + vec2(t.x, -t.y)).rgb + + texture(uSrc, vUV + vec2(-t.x, t.y)).rgb + texture(uSrc, vUV + vec2(t.x, t.y)).rgb; + fragColor = vec4(s / 16.0, 1.0); +} diff --git a/assets/shaders/common.glsl b/assets/shaders/common.glsl new file mode 100644 index 0000000..3c5a0eb --- /dev/null +++ b/assets/shaders/common.glsl @@ -0,0 +1,200 @@ +// Shared frame data and helpers. Mirrors FrameUBO in Renderer.cpp (std140). +layout(std140, binding = 0) uniform Frame { + mat4 uView; + mat4 uProj; + mat4 uViewProj; + mat4 uInvViewProj; + mat4 uLightViewProj; + vec4 uCamPos; // xyz, w = time (seconds) + vec4 uCamParams; // near, far, focus distance, unused + vec4 uScreen; // target width, height, 1/width, 1/height + vec4 uSunDir; // xyz toward sun, w = intensity + vec4 uSunColor; // rgb + vec4 uSkyZenith; // rgb, w = ambient intensity + vec4 uSkyHorizon; // rgb, w = camera-side fill light intensity + vec4 uSkyGround; // rgb + vec4 uFogColor; // rgb, w = density + vec4 uFogParams; // height falloff, base height, noise amount, anisotropy g + vec4 uFogParams2; // god ray intensity, max distance, steps, unused + vec4 uWind; // dir.xy, strength, gust speed + vec4 uInteractor; // xyz, radius + vec4 uBlob; // xyz, radius + vec4 uClipPlane; // world-space plane for gl_ClipDistance[0] + vec4 uBillboardRight;// camera-facing billboard right vector + vec4 uShadowRight; // sun-facing billboard right vector (shadow cards) + vec4 uCookie; // strength, scale, speed, coverage + vec4 uShadowParams; // texel size, softness, unused, unused + vec4 uShaft; // god ray beam strength, coverage, pattern scale, unused + 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 + ivec4 uLightCount; + vec4 uLightPos[16]; // xyz, w = radius + vec4 uLightColor[16]; +}; + +#define uTime uCamPos.w +#define PI 3.14159265359 + +layout(binding = 8) uniform sampler2DShadow uShadowMap; +layout(binding = 9) uniform sampler2D uGroundAOTex; + +// ---------------------------------------------------------------- noise ---- +float hash12(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} +float hash13(vec3 p3) { + p3 = fract(p3 * 0.1031); + p3 += dot(p3, p3.zyx + 31.32); + return fract((p3.x + p3.y) * p3.z); +} +float vnoise(vec2 p) { + vec2 i = floor(p), f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(mix(hash12(i), hash12(i + vec2(1, 0)), u.x), + mix(hash12(i + vec2(0, 1)), hash12(i + vec2(1, 1)), u.x), u.y); +} +float vnoise3(vec3 p) { + vec3 i = floor(p), f = fract(p); + vec3 u = f * f * (3.0 - 2.0 * f); + float a = mix(mix(hash13(i), hash13(i + vec3(1, 0, 0)), u.x), + mix(hash13(i + vec3(0, 1, 0)), hash13(i + vec3(1, 1, 0)), u.x), u.y); + float b = mix(mix(hash13(i + vec3(0, 0, 1)), hash13(i + vec3(1, 0, 1)), u.x), + mix(hash13(i + vec3(0, 1, 1)), hash13(i + vec3(1, 1, 1)), u.x), u.y); + return mix(a, b, u.z); +} +float fbm2(vec2 p) { + float a = 0.5, s = 0.0; + for (int i = 0; i < 4; ++i) { + s += a * vnoise(p); + p = p * 2.03 + 17.1; + a *= 0.5; + } + return s / 0.9375; +} +// Interleaved gradient noise: cheap per-pixel dither for ray marching. +float ign(vec2 p) { return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); } + +// ---------------------------------------------------------------- depth ---- +float linearDepth(float d) { + float n = uCamParams.x, f = uCamParams.y; + float z = d * 2.0 - 1.0; + return 2.0 * n * f / (f + n - z * (f - n)); +} +vec3 worldFromDepth(vec2 uv, float d) { + vec4 p = uInvViewProj * vec4(uv * 2.0 - 1.0, d * 2.0 - 1.0, 1.0); + return p.xyz / p.w; +} + +// ------------------------------------------------------------- lighting ---- +vec3 shadowCoord(vec3 wp) { + vec4 lc = uLightViewProj * vec4(wp, 1.0); + return lc.xyz / lc.w * 0.5 + 0.5; +} +float sunShadow(vec3 wp) { + vec3 p = shadowCoord(wp); + if (any(lessThan(p.xy, vec2(0.0))) || any(greaterThan(p.xyz, vec3(1.0)))) return 1.0; + vec2 o = vec2(uShadowParams.x * uShadowParams.y); + float s = 0.0; + for (int y = -1; y <= 1; ++y) + for (int x = -1; x <= 1; ++x) + s += texture(uShadowMap, vec3(p.xy + vec2(x, y) * o, p.z)); + return s / 9.0; +} +float sunShadowFast(vec3 wp) { + vec3 p = shadowCoord(wp); + if (any(lessThan(p.xy, vec2(0.0))) || any(greaterThan(p.xyz, vec3(1.0)))) return 1.0; + return texture(uShadowMap, p); +} +float canopyPattern(vec3 wp, float scale, float coverage, float octaves) { + const float canopyHeight = 14.0; + vec3 L = uSunDir.xyz; + vec2 p = wp.xz + L.xz / max(L.y, 0.15) * (canopyHeight - wp.y); + p = p * scale + uTime * uCookie.z * vec2(1.0, 0.6); + float n = octaves > 1.5 ? fbm2(p) : vnoise(p) * 0.75 + vnoise(p * 2.7 + 9.1) * 0.25; + return smoothstep(coverage - 0.06, coverage + 0.06, n); +} +// Dappled light from an implied tree canopy high above the scene, projected +// along the sun direction onto surfaces. +float canopyCookie(vec3 wp) { + if (uCookie.x <= 0.0) return 1.0; + return mix(1.0, canopyPattern(wp, uCookie.y, uCookie.w, 4.0), uCookie.x); +} +// Sparse beams through the same implied canopy, used inside fog so sunlight +// gathers into distinct god ray shafts instead of a uniform glow. +float shaftCookie(vec3 wp) { + if (uShaft.x <= 0.0) return 1.0; + return mix(1.0, canopyPattern(wp, uShaft.z, uShaft.y, 1.0), uShaft.x); +} +float sunVisibility(vec3 wp) { return sunShadow(wp) * canopyCookie(wp); } + +vec3 hemiAmbient(vec3 n) { + vec3 sky = mix(uSkyHorizon.rgb, uSkyZenith.rgb, 0.45); + return mix(uSkyGround.rgb, sky, n.y * 0.5 + 0.5) * uSkyZenith.w; +} +vec3 pointLights(vec3 wp, vec3 n) { + vec3 sum = vec3(0.0); + for (int i = 0; i < uLightCount.x; ++i) { + vec3 d = uLightPos[i].xyz - wp; + float dist = length(d); + float r = uLightPos[i].w; + if (dist >= r) continue; + float att = 1.0 - (dist * dist) / (r * r); + att *= att; + float ndl = clamp((dot(n, d / max(dist, 1e-4)) + 0.4) / 1.4, 0.0, 1.0); + sum += uLightColor[i].rgb * att * ndl; + } + return sum; +} + +// Baked contact occlusion under scenery (tree bases, bushes, rocks). `height` +// is the shaded point's height above the ground it stands on: the darkening +// only applies near the ground. +float groundAO(vec3 wp, float height) { + if (uGroundAO.z <= 0.0) return 1.0; + float ao = texture(uGroundAOTex, (wp.xz - uGroundAO.xy) * uGroundAO.zw).r; + return mix(ao, 1.0, smoothstep(0.0, 1.2, height)); +} + +// Rain-soaked surfaces: darker albedo plus a sky/sun sheen on upward faces. +vec3 wetSurface(vec3 albedo, vec3 n, vec3 V, float vis, out vec3 sheen) { + float wet = uWeather.y * smoothstep(0.2, 0.8, n.y); + vec3 R = reflect(-V, n); + float F = 0.04 + 0.96 * pow(1.0 - max(dot(n, V), 0.0), 5.0); + sheen = wet * (uSkyHorizon.rgb * uSkyZenith.w * F * 0.6 + + uSunColor.rgb * uSunDir.w * pow(max(dot(R, uSunDir.xyz), 0.0), 60.0) * 0.5 * vis); + return albedo * mix(1.0, 0.68, uWeather.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); + 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)); + } + c += uSunColor.rgb * (pow(sd, 900.0) * 40.0 + pow(sd, 10.0) * 0.3); + return c; +} + +// Animated caustic web (after joltz0r's "water turbulence"). +float caustics(vec2 uv, float time) { + vec2 p = mod(uv * 6.28318, 6.28318) - 250.0; + vec2 i = p; + float c = 1.0; + const float inten = 0.005; + for (int n = 0; n < 4; ++n) { + float t = time * (1.0 - (3.5 / float(n + 1))); + i = p + vec2(cos(t - i.x) + sin(t + i.y), sin(t - i.y) + cos(t + i.x)); + c += 1.0 / length(vec2(p.x / (sin(i.x + t) / inten), p.y / (cos(i.y + t) / inten))); + } + c /= 4.0; + c = 1.17 - pow(c, 1.4); + return pow(abs(c), 8.0); +} diff --git a/assets/shaders/depth.frag b/assets/shaders/depth.frag new file mode 100644 index 0000000..804bf9c --- /dev/null +++ b/assets/shaders/depth.frag @@ -0,0 +1,3 @@ +#version 450 core +// Depth-only pass (shadow map). +void main() {} diff --git a/assets/shaders/dof_blur.frag b/assets/shaders/dof_blur.frag new file mode 100644 index 0000000..6f195ae --- /dev/null +++ b/assets/shaders/dof_blur.frag @@ -0,0 +1,35 @@ +#version 450 core +// Scatter-as-gather bokeh blur over a golden-angle disc at half resolution. +// Samples farther than the center cannot spread over it (keeps in-focus +// subjects from smearing into the background); nearer samples can (near +// field blur bleeds over sharp areas like a real lens). + +in vec2 vUV; +layout(binding = 0) uniform sampler2D uHalf; +uniform float uMaxRadius; +uniform vec2 uTexel; +layout(location = 0) out vec4 fragColor; + +const int SAMPLES = 64; + +void main() { + vec4 c0 = texture(uHalf, vUV); + float cc = c0.a; + vec3 acc = c0.rgb; + float wsum = 1.0; + float nearCoverage = 0.0; + for (int i = 0; i < SAMPLES; ++i) { + float r = sqrt((float(i) + 0.5) / float(SAMPLES)) * uMaxRadius; + float th = float(i) * 2.39996323; + vec2 o = vec2(cos(th), sin(th)) * r; + vec4 s = textureLod(uHalf, vUV + o * uTexel, 0.0); + float cs = s.a; + float e = abs(cs); + if (cs > cc) e = min(e, abs(cc)); + float w = clamp(e * uMaxRadius - r + 1.0, 0.0, 1.0); + acc += s.rgb * w; + wsum += w; + if (cs < 0.0) nearCoverage = max(nearCoverage, w * abs(cs)); + } + fragColor = vec4(acc / wsum, max(abs(cc), nearCoverage)); +} diff --git a/assets/shaders/dof_common.glsl b/assets/shaders/dof_common.glsl new file mode 100644 index 0000000..01a76bc --- /dev/null +++ b/assets/shaders/dof_common.glsl @@ -0,0 +1,12 @@ +uniform vec4 uDof; // focus distance, focus range, transition, max radius (half-res px) +uniform float uTiltShift; + +// Signed CoC in [-1, 1]. Distance-based blur plus an optional screen-space +// tilt-shift band that exaggerates the miniature/diorama look. +float cocFromDepth(float linDepth, vec2 uv) { + float s = linDepth - uDof.x; + float a = smoothstep(uDof.y, uDof.y + uDof.z, abs(s)); + float tilt = uTiltShift * smoothstep(0.18, 0.5, abs(uv.y - 0.5)); + float sgn = abs(s) > 0.25 ? sign(s) : sign(uv.y - 0.5); + return max(a, tilt) * sgn; +} diff --git a/assets/shaders/dof_composite.frag b/assets/shaders/dof_composite.frag new file mode 100644 index 0000000..724fabe --- /dev/null +++ b/assets/shaders/dof_composite.frag @@ -0,0 +1,17 @@ +#version 450 core +#include "common.glsl" +#include "dof_common.glsl" + +in vec2 vUV; +layout(binding = 0) uniform sampler2D uSharp; +layout(binding = 1) uniform sampler2D uBlurred; +layout(binding = 2) uniform sampler2D uDepth; +layout(location = 0) out vec4 fragColor; + +void main() { + vec3 sharp = texture(uSharp, vUV).rgb; + vec4 blurred = texture(uBlurred, vUV); + float coc = abs(cocFromDepth(linearDepth(texture(uDepth, vUV).r), vUV)); + float m = smoothstep(0.02, 0.35, max(coc, blurred.a)); + fragColor = vec4(mix(sharp, blurred.rgb, m), 1.0); +} diff --git a/assets/shaders/dof_prep.frag b/assets/shaders/dof_prep.frag new file mode 100644 index 0000000..cdb5aa2 --- /dev/null +++ b/assets/shaders/dof_prep.frag @@ -0,0 +1,15 @@ +#version 450 core +#include "common.glsl" +#include "dof_common.glsl" +// Half-res: downsampled color + signed circle of confusion (near < 0 < far). + +in vec2 vUV; +layout(binding = 0) uniform sampler2D uColor; +layout(binding = 1) uniform sampler2D uDepth; +layout(location = 0) out vec4 fragColor; + +void main() { + vec3 c = texture(uColor, vUV).rgb; // bilinear = 2x2 box at half res + float coc = cocFromDepth(linearDepth(texture(uDepth, vUV).r), vUV); + fragColor = vec4(c, coc); +} diff --git a/assets/shaders/fog_composite.frag b/assets/shaders/fog_composite.frag new file mode 100644 index 0000000..9ebda9f --- /dev/null +++ b/assets/shaders/fog_composite.frag @@ -0,0 +1,33 @@ +#version 450 core +#include "common.glsl" +// Depth-aware upsample of the half-res volumetric buffer, applied to the scene. + +in vec2 vUV; +layout(binding = 0) uniform sampler2D uColor; +layout(binding = 1) uniform sampler2D uDepth; +layout(binding = 2) uniform sampler2D uVolume; +uniform vec2 uVolumeTexel; +uniform int uEnabled; + +layout(location = 0) out vec4 fragColor; + +void main() { + vec3 c = texture(uColor, vUV).rgb; + if (uEnabled == 0) { + fragColor = vec4(c, 1.0); + return; + } + float d0 = linearDepth(texture(uDepth, vUV).r); + vec4 acc = vec4(0.0); + float wsum = 0.0; + for (int y = -1; y <= 1; ++y) + for (int x = -1; x <= 1; ++x) { + vec2 uv = vUV + vec2(x, y) * uVolumeTexel; + float ds = linearDepth(texture(uDepth, uv).r); + float w = exp(-abs(ds - d0) / (0.04 * d0 + 0.05)) * (x == 0 && y == 0 ? 2.0 : 1.0); + acc += texture(uVolume, uv) * w; + wsum += w; + } + vec4 v = acc / max(wsum, 1e-4); + fragColor = vec4(c * v.a + v.rgb, 1.0); +} diff --git a/assets/shaders/fullscreen.vert b/assets/shaders/fullscreen.vert new file mode 100644 index 0000000..c159cd7 --- /dev/null +++ b/assets/shaders/fullscreen.vert @@ -0,0 +1,8 @@ +#version 450 core +// Fullscreen triangle from gl_VertexID; draw with 3 vertices, no buffers. +out vec2 vUV; +void main() { + vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + vUV = p; + gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); +} diff --git a/assets/shaders/mesh.frag b/assets/shaders/mesh.frag new file mode 100644 index 0000000..060a46c --- /dev/null +++ b/assets/shaders/mesh.frag @@ -0,0 +1,83 @@ +#version 450 core +#include "common.glsl" +// High-resolution 3D scenery (trees, bushes, rocks). Foliage uses wrap +// lighting on its rounded custom normals plus sun translucency. + +in VS { + vec3 worldPos; + vec3 normal; + vec2 uv; + vec3 color; + float height; +} v; + +layout(binding = 0) uniform sampler2D uTex; +uniform vec4 uBaseColor; +uniform int uAlphaMask; +uniform float uCutoff; +uniform int uFoliage; +uniform int uOccluderFade; // 1 in the main view: dither away scenery hiding the player +uniform vec3 uEmissive; // linear HDR; multiplied by the emissive texture (unit 1) +layout(binding = 1) uniform sampler2D uEmissiveTex; + +layout(location = 0) out vec4 fragColor; + +void main() { + vec4 tex = texture(uTex, v.uv); + if (uAlphaMask == 1) { + // Sharpen alpha by its screen-space rate of change: keeps leaf coverage + // stable through the mip chain instead of thinning out with distance. + float a = (tex.a - uCutoff) / max(fwidth(tex.a), 1e-4) + 0.5; + if (a < 0.5) discard; + } + if (uOccluderFade == 1 && uBlob.w > 0.0) { + vec4 pc = uViewProj * vec4(uBlob.xyz + vec3(0.0, 0.6, 0.0), 1.0); + vec2 ps = pc.xy / pc.w * 0.5 + 0.5; + vec2 d = (gl_FragCoord.xy * uScreen.zw - ps) * vec2(uScreen.x * uScreen.w, 1.0); + float fragDepth = (uViewProj * vec4(v.worldPos, 1.0)).w; + float fade = (1.0 - smoothstep(0.07, 0.15, length(d))) * step(fragDepth, pc.w - 1.0); + if (ign(gl_FragCoord.xy) < fade * 0.85) discard; + } + + vec3 wp = v.worldPos; + vec3 n = normalize(v.normal); + if (uFoliage == 0 && !gl_FrontFacing) n = -n; + vec3 L = uSunDir.xyz; + vec3 V = normalize(uCamPos.xyz - wp); + vec3 albedo = tex.rgb * uBaseColor.rgb * v.color; + float gao = groundAO(wp, v.height); + + float ndl = dot(n, L); + float diffuse = uFoliage == 1 ? clamp((ndl + 0.45) / 1.45, 0.0, 1.0) : max(ndl, 0.0); + // Offset along the normal and toward the sun to avoid acne on thin cards. + float vis = sunShadow(wp + n * 0.06 + L * 0.08) * canopyCookie(wp); + if (uFoliage == 1) vis = mix(0.3, 1.0, vis); // light scatters through leaves: soft self-shadowing + vec3 sunRad = uSunColor.rgb * uSunDir.w; + + vec3 toCam = cross(uBillboardRight.xyz, vec3(0.0, 1.0, 0.0)); + vec3 amb = hemiAmbient(n) + uSkyHorizon.rgb * uSkyHorizon.w * max(dot(n, toCam), 0.0); + vec3 light = sunRad * diffuse * vis * mix(1.0, gao, 0.7) + amb * gao + pointLights(wp, n); + + if (uFoliage == 1) { + // Backlit leaves glow; sunlit rims pick up a warm sheen. + float back = pow(max(dot(-V, L), 0.0), 4.0); + light += sunRad * back * vis * 0.35 * vec3(1.0, 1.05, 0.7); + float rim = pow(1.0 - max(dot(n, V), 0.0), 3.0) * max(ndl, 0.0); + light += sunRad * rim * vis * 0.25; + } else { + // Subtle sheen on rocks and bark. + vec3 H = normalize(L + V); + light += sunRad * pow(max(dot(n, H), 0.0), 24.0) * 0.06 * vis; + } + + vec3 sheen = vec3(0.0); + if (uWeather.y > 0.0) albedo = wetSurface(albedo, n, V, vis, sheen); + vec3 col = albedo * light + sheen * gao; + col += texture(uEmissiveTex, v.uv).rgb * uEmissive; + if (uBlob.w > 0.0) { // player's contact shadow + float d = length(wp.xz - uBlob.xz) / uBlob.w; + float near = 1.0 - smoothstep(0.0, 0.6, abs(wp.y - uBlob.y)); + col *= 1.0 - 0.4 * (1.0 - smoothstep(0.3, 1.0, d)) * near; + } + fragColor = vec4(col, 1.0); +} diff --git a/assets/shaders/mesh.vert b/assets/shaders/mesh.vert new file mode 100644 index 0000000..b346b40 --- /dev/null +++ b/assets/shaders/mesh.vert @@ -0,0 +1,54 @@ +#version 450 core +#include "common.glsl" + +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec3 aNormal; +layout(location = 2) in vec2 aUV; +layout(location = 3) in vec4 aColor; // rgb = tint/AO, a = wind weight +layout(location = 4) in vec4 iPosYaw; // pivot xyz, yaw +layout(location = 5) in vec4 iScaleTint; // scale xyz, tint + +uniform float uWindScale; + +out VS { + vec3 worldPos; + vec3 normal; + vec2 uv; + vec3 color; + float height; // above the model's base (ground contact AO) +} o; + +void main() { + float c = cos(iPosYaw.w), s = sin(iPosYaw.w); + vec3 lp = aPos * iScaleTint.xyz; + vec3 p = vec3(c * lp.x + s * lp.z, lp.y, -s * lp.x + c * lp.z) + iPosYaw.xyz; + vec3 ln = aNormal / iScaleTint.xyz; + vec3 n = normalize(vec3(c * ln.x + s * ln.z, ln.y, -s * ln.x + c * ln.z)); + + float w = aColor.a * uWindScale; + if (w > 0.0) { + vec3 pivot = iPosYaw.xyz; + float ph = dot(pivot.xz, vec2(0.37, 0.61)); + vec2 wdir = normalize(uWind.xy + 1e-5); + float gust = vnoise(pivot.xz * 0.12 - wdir * uTime * uWind.w * 0.6); + // Whole-crown sway grows with height; leaves add a fast, small flutter. + float sway = (sin(uTime * 1.1 + ph) * 0.5 + sin(uTime * 1.9 + ph * 1.3) * 0.25 + gust * 1.2) * uWind.z; + float h = max(lp.y, 0.0); + p.xz += wdir * sway * w * h * 0.35; + // Leaf flutter scales with wind strength, so storms visibly thrash the canopy. + p += n * sin(uTime * (3.0 + uWind.w * 2.0) + dot(aPos, vec3(7.1, 11.3, 5.7)) + ph) * 0.4 * uWind.z * w; + // Low foliage (bushes) parts around the player like the grass does. + vec3 toP = p - uInteractor.xyz; + float d = length(toP.xz); + float push = clamp(1.0 - d / (uInteractor.w * 1.3), 0.0, 1.0) * step(abs(toP.y), 1.2); + p.xz += (d > 1e-3 ? toP.xz / d : vec2(0.0)) * push * 0.3 * w; + } + + o.worldPos = p; + o.normal = n; + o.uv = aUV; + o.color = aColor.rgb * iScaleTint.w; + o.height = lp.y; + gl_Position = uViewProj * vec4(p, 1.0); + gl_ClipDistance[0] = dot(vec4(p, 1.0), uClipPlane); +} diff --git a/assets/shaders/mesh_depth.frag b/assets/shaders/mesh_depth.frag new file mode 100644 index 0000000..1e4861a --- /dev/null +++ b/assets/shaders/mesh_depth.frag @@ -0,0 +1,18 @@ +#version 450 core +// Shadow pass for 3D scenery: alpha-tested so leaves cast dappled shadows. + +in VS { + vec3 worldPos; + vec3 normal; + vec2 uv; + vec3 color; + float height; +} v; + +layout(binding = 0) uniform sampler2D uTex; +uniform int uAlphaMask; +uniform float uCutoff; + +void main() { + if (uAlphaMask == 1 && texture(uTex, v.uv).a < uCutoff) discard; +} diff --git a/assets/shaders/particles.frag b/assets/shaders/particles.frag new file mode 100644 index 0000000..763981a --- /dev/null +++ b/assets/shaders/particles.frag @@ -0,0 +1,56 @@ +#version 450 core +#include "common.glsl" +// Premultiplied output: glows and fire write alpha 0 (pure additive), sparks +// and smoke write coverage (alpha-blended). Blend func is ONE, ONE_MINUS_SRC_ALPHA. + +in vec2 vUV; +in vec4 vColor; +flat in int vKind; +in vec2 vLifeSeed; + +layout(location = 0) out vec4 fragColor; + +void main() { + vec2 p = vUV * 2.0 - 1.0; + float r = length(p); + float life = vLifeSeed.x, seed = vLifeSeed.y; + + if (vKind == 2) { // spark / droplet + float a = (1.0 - smoothstep(0.6, 1.0, r)) * vColor.a; + fragColor = vec4(vColor.rgb * a, a); + return; + } + if (vKind == 3) { // flame: teardrop eroded by scrolling noise, more as it ages + float taper = 1.0 + 0.7 * (p.y * 0.5 + 0.5); + float d = length(vec2(p.x * taper, p.y * 0.9 + 0.1)); + vec2 q = vec2(p.x * 2.5, p.y * 2.0 - uTime * 3.5) + seed * 37.0; + float n = vnoise(q) * 0.6 + vnoise(q * 2.3 + 11.0) * 0.4; + float shape = 1.0 - smoothstep(0.45, 0.85, d + (n - 0.5) * (0.6 + 0.9 * life)); + float core = 1.0 - smoothstep(0.0, 0.55, d); + float a = shape * vColor.a; + // Mostly additive, but each tongue also dims what is behind it a little, + // so flames stay saturated orange over bright daytime ground. + fragColor = vec4(vColor.rgb * a * (0.7 + 0.8 * core), a * 0.3); + return; + } + if (vKind == 4) { // ember + float core = exp(-r * r * 10.0); + fragColor = vec4(vColor.rgb * vColor.a * core, 0.0); + return; + } + if (vKind == 5) { // smoke puff + vec2 q = p * 1.6 + seed * 23.0 + vec2(0.0, -uTime * 0.3); + float n = vnoise(q) * 0.6 + vnoise(q * 2.1 + 5.0) * 0.4; + float a = (1.0 - smoothstep(0.2, 1.0, r + (n - 0.5) * 0.7)) * vColor.a; + fragColor = vec4(vColor.rgb * a, a); + return; + } + if (vKind == 6) { // fire glow halo + float g = exp(-r * r * 3.5) - 0.03; + fragColor = vec4(vColor.rgb * max(g, 0.0), 0.0); + return; + } + float core = 1.0 - smoothstep(0.0, 0.2, r); + float halo = exp(-r * r * 9.0) * 0.45; + fragColor = vec4(vColor.rgb * vColor.a * (core * 1.5 + halo), 0.0); +} diff --git a/assets/shaders/particles.vert b/assets/shaders/particles.vert new file mode 100644 index 0000000..629b8fd --- /dev/null +++ b/assets/shaders/particles.vert @@ -0,0 +1,50 @@ +#version 450 core +#include "common.glsl" + +layout(location = 0) in vec4 iPosSize; +layout(location = 1) in vec4 iColor; // rgb, alpha +// x = kind (0 firefly, 1 mote, 2 spark, 3 flame, 4 ember, 5 smoke, 6 glow), +// y = age / life, z = per-particle random seed +layout(location = 2) in vec4 iParams; + +out vec2 vUV; +out vec4 vColor; +flat out int vKind; +out vec2 vLifeSeed; + +void main() { + vec2 corner = vec2(float(gl_VertexID & 1), float(gl_VertexID >> 1)) - 0.5; + vec3 camRight = vec3(uView[0][0], uView[1][0], uView[2][0]); + vec3 camUp = vec3(uView[0][1], uView[1][1], uView[2][1]); + int kind = int(iParams.x + 0.5); + float seed = iParams.z; + + vec2 offset = corner; + float extent = 5.0; // fireflies / motes need room for their halo + if (kind == 2 || kind == 4 || kind == 6) extent = 1.0; + if (kind == 3) { // flame tongues are taller than wide + extent = 1.0; + offset *= vec2(1.0, 1.7); + } + if (kind == 5) { // smoke puffs rotate slowly + extent = 1.0; + float a = seed * 6.2832 + iParams.y * 1.5; + offset = mat2(cos(a), sin(a), -sin(a), cos(a)) * offset; + } + vec3 c = iPosSize.xyz; + vec3 wp = c + (camRight * offset.x + camUp * offset.y) * iPosSize.w * extent; + + vec4 col = iColor; + if (kind == 1) { // dust motes only sparkle inside sun shafts + float vis = sunShadowFast(c) * shaftCookie(c); + col.rgb *= uSunColor.rgb * uSunDir.w * 0.8; + col.a *= vis; + } + if (kind == 5) // smoke is lit by the sky and warmed by nearby fire light + col.rgb *= hemiAmbient(vec3(0, 1, 0)) * 2.5 + pointLights(c, vec3(0, -1, 0)) * 0.4; + vUV = corner + 0.5; + vColor = col; + vKind = kind; + vLifeSeed = iParams.yz; + gl_Position = uViewProj * vec4(wp, 1.0); +} diff --git a/assets/shaders/pixelart.glsl b/assets/shaders/pixelart.glsl new file mode 100644 index 0000000..e0c7353 --- /dev/null +++ b/assets/shaders/pixelart.glsl @@ -0,0 +1,19 @@ +// Fragment-stage only (uses screen-space derivatives). +// "Sharp bilinear": crisp texel edges when magnified, antialiased boundaries +// and correct mip selection when minified (unlike GL_NEAREST shimmer). +vec4 sharpSample(sampler2D tex, vec2 uv) { + vec2 size = vec2(textureSize(tex, 0)); + vec2 px = uv * size; + vec2 seam = floor(px + 0.5); + vec2 fw = max(fwidth(px), vec2(1e-5)); + px = seam + clamp((px - seam) / fw, -0.5, 0.5); + return textureGrad(tex, px / size, dFdx(uv), dFdy(uv)); +} +vec4 sharpSampleArray(sampler2DArray tex, vec2 uv, float layer) { + vec2 size = vec2(textureSize(tex, 0).xy); + vec2 px = uv * size; + vec2 seam = floor(px + 0.5); + vec2 fw = max(fwidth(px), vec2(1e-5)); + px = seam + clamp((px - seam) / fw, -0.5, 0.5); + return textureGrad(tex, vec3(px / size, layer), dFdx(uv), dFdy(uv)); +} diff --git a/assets/shaders/rain.frag b/assets/shaders/rain.frag new file mode 100644 index 0000000..6effc22 --- /dev/null +++ b/assets/shaders/rain.frag @@ -0,0 +1,15 @@ +#version 450 core +#include "common.glsl" +// Thin, slightly bright streak lit by the overcast sky. Premultiplied alpha. + +in vec2 vLocal; +in float vAlpha; +layout(location = 0) out vec4 fragColor; + +void main() { + float across = 1.0 - abs(vLocal.x); + float along = (1.0 - vLocal.y) * smoothstep(0.0, 0.15, vLocal.y + 0.05); // bright head, fading tail + float a = across * across * along * vAlpha * 0.6; + vec3 c = hemiAmbient(vec3(0.0, 1.0, 0.0)) * 2.0 + uSunColor.rgb * uSunDir.w * 0.2; + fragColor = vec4(c * a, a); +} diff --git a/assets/shaders/rain.vert b/assets/shaders/rain.vert new file mode 100644 index 0000000..c097179 --- /dev/null +++ b/assets/shaders/rain.vert @@ -0,0 +1,43 @@ +#version 450 core +#include "common.glsl" +// Fully procedural rain: each instance is one streak in a box that follows +// the camera target. Drops are fixed in world space (the box wraps around +// them), fall at uWeather.z and slant with the wind. + +uniform vec3 uRainCenter; +uniform vec2 uRainBox; // half extent (xz), height + +out vec2 vLocal; // x across the streak (-1..1), y along it (0 = head) +out float vAlpha; + +void main() { + int id = gl_InstanceID; + vec3 h = vec3(hash12(vec2(float(id), 1.37)), hash12(vec2(float(id), 7.13)), hash12(vec2(float(id), 3.71))); + float E = uRainBox.x, H = uRainBox.y; + float speed = uWeather.z * (0.85 + 0.3 * h.x); + vec2 wdir = normalize(uWind.xy + 1e-5); + vec3 vel = vec3(wdir.x, 0.0, wdir.y) * uWind.z * 28.0 + vec3(0.0, -speed, 0.0); + + float age = fract(h.y + uTime * speed / H); // 0 = top of the box + float tFall = age * H / speed; + vec3 p; + p.y = uRainCenter.y + H * 0.75 - age * H; + vec2 base = h.xz * 2.0 * E + vel.xz * tFall; + p.xz = uRainCenter.xz + mod(base - uRainCenter.xz + E, 2.0 * E) - E; + + vec3 dir = normalize(vel); + float len = speed * 0.032; + vec3 toCam = uCamPos.xyz - p; + float dist = length(toCam); + vec3 side = normalize(cross(dir, toCam / max(dist, 1e-3))); + float width = 0.008 + dist * 0.0006; // never thinner than ~1 px + + vec2 corner = vec2(float(gl_VertexID & 1) * 2.0 - 1.0, float(gl_VertexID >> 1)); + vec3 wp = p - dir * len * corner.y + side * width * corner.x; + + vLocal = corner; + // Fade near the camera (big blurry drops) and at the box edges. + vec2 rel = abs(p.xz - uRainCenter.xz) / E; + vAlpha = smoothstep(1.5, 5.0, dist) * (1.0 - smoothstep(0.75, 1.0, max(rel.x, rel.y))) * (0.6 + 0.4 * h.z); + gl_Position = uViewProj * vec4(wp, 1.0); +} diff --git a/assets/shaders/sky.frag b/assets/shaders/sky.frag new file mode 100644 index 0000000..81cd81f --- /dev/null +++ b/assets/shaders/sky.frag @@ -0,0 +1,11 @@ +#version 450 core +#include "common.glsl" + +in vec2 vUV; +layout(location = 0) out vec4 fragColor; + +void main() { + vec3 far = worldFromDepth(vUV, 1.0); + vec3 dir = normalize(far - uCamPos.xyz); + fragColor = vec4(skyColor(dir), 1.0); +} diff --git a/assets/shaders/ssao.frag b/assets/shaders/ssao.frag new file mode 100644 index 0000000..4b4d46b --- /dev/null +++ b/assets/shaders/ssao.frag @@ -0,0 +1,55 @@ +#version 450 core +#include "common.glsl" +// Half-res world-space hemisphere SSAO. Normals are reconstructed from depth +// (picking the flatter neighbour per axis to avoid smearing across edges). + +in vec2 vUV; +layout(binding = 0) uniform sampler2D uDepth; +uniform float uRadius; +uniform float uIntensity; +layout(location = 0) out vec4 fragColor; + +const int SAMPLES = 14; + +vec3 posAt(vec2 uv) { return worldFromDepth(uv, texture(uDepth, uv).r); } + +void main() { + float d = texture(uDepth, vUV).r; + if (d >= 1.0) { + fragColor = vec4(1.0); + return; + } + vec3 P = worldFromDepth(vUV, d); + vec2 t = 1.0 / vec2(textureSize(uDepth, 0)); + vec3 px = posAt(vUV + vec2(t.x, 0)), nx = posAt(vUV - vec2(t.x, 0)); + vec3 py = posAt(vUV + vec2(0, t.y)), ny = posAt(vUV - vec2(0, t.y)); + vec3 dx = length(px - P) < length(P - nx) ? px - P : P - nx; + vec3 dy = length(py - P) < length(P - ny) ? py - P : P - ny; + vec3 N = normalize(cross(dx, dy)); + if (dot(N, uCamPos.xyz - P) < 0.0) N = -N; + + float rot = ign(gl_FragCoord.xy) * 6.2831853; + vec3 T = normalize(cross(N, abs(N.y) < 0.9 ? vec3(0, 1, 0) : vec3(1, 0, 0))); + vec3 B = cross(N, T); + float centerLin = linearDepth(d); + // Larger radius further away keeps the effect stable on screen. + float radius = uRadius * (0.8 + centerLin * 0.01); + + float occ = 0.0; + for (int i = 0; i < SAMPLES; ++i) { + float fi = (float(i) + 0.5) / float(SAMPLES); + float a = float(i) * 2.399963 + rot; + float r = sqrt(fi); + float z = sqrt(max(0.0, 1.0 - r * r)); + vec3 k = (T * cos(a) * r + B * sin(a) * r + N * z) * mix(0.15, 1.0, fi * fi); + vec3 S = P + N * 0.03 + k * radius; + vec4 clip = uViewProj * vec4(S, 1.0); + vec2 suv = clip.xy / clip.w * 0.5 + 0.5; + if (any(lessThan(suv, vec2(0.0))) || any(greaterThan(suv, vec2(1.0)))) continue; + float sceneLin = linearDepth(texture(uDepth, suv).r); + float range = smoothstep(0.0, 1.0, radius / max(abs(centerLin - sceneLin), 1e-3)); + occ += step(sceneLin, clip.w - 0.02) * range; + } + float ao = 1.0 - occ / float(SAMPLES); + fragColor = vec4(pow(clamp(ao, 0.0, 1.0), uIntensity * 1.6)); +} diff --git a/assets/shaders/ssao_apply.frag b/assets/shaders/ssao_apply.frag new file mode 100644 index 0000000..0275563 --- /dev/null +++ b/assets/shaders/ssao_apply.frag @@ -0,0 +1,29 @@ +#version 450 core +#include "common.glsl" +// Depth-aware upsample of the AO buffer; output is multiplied into the HDR +// target by the blend unit (DST_COLOR, ZERO). + +in vec2 vUV; +layout(binding = 0) uniform sampler2D uAo; +layout(binding = 1) uniform sampler2D uDepth; +uniform vec2 uAoTexel; +layout(location = 0) out vec4 fragColor; + +void main() { + float raw = texture(uDepth, vUV).r; + if (raw >= 1.0) { + fragColor = vec4(1.0); + return; + } + float d0 = linearDepth(raw); + float sum = 0.0, wsum = 0.0; + for (int y = -1; y <= 1; ++y) + for (int x = -1; x <= 1; ++x) { + vec2 uv = vUV + vec2(x, y) * uAoTexel; + float d = linearDepth(texture(uDepth, uv).r); + float w = exp(-abs(d - d0) / (0.03 * d0 + 0.05)); + sum += texture(uAo, uv).r * w; + wsum += w; + } + fragColor = vec4(vec3(sum / max(wsum, 1e-4)), 1.0); +} diff --git a/assets/shaders/ssao_blur.frag b/assets/shaders/ssao_blur.frag new file mode 100644 index 0000000..99da6a8 --- /dev/null +++ b/assets/shaders/ssao_blur.frag @@ -0,0 +1,22 @@ +#version 450 core +#include "common.glsl" +// Separable depth-aware blur of the half-res AO buffer. + +in vec2 vUV; +layout(binding = 0) uniform sampler2D uAo; +layout(binding = 1) uniform sampler2D uDepth; +uniform vec2 uDir; // texel step along the blur axis +layout(location = 0) out vec4 fragColor; + +void main() { + float d0 = linearDepth(texture(uDepth, vUV).r); + float sum = 0.0, wsum = 0.0; + for (int i = -4; i <= 4; ++i) { + vec2 uv = vUV + uDir * float(i); + float d = linearDepth(texture(uDepth, uv).r); + float w = exp(-float(i * i) / 12.0) * exp(-abs(d - d0) / (0.03 * d0 + 0.05)); + sum += texture(uAo, uv).r * w; + wsum += w; + } + fragColor = vec4(sum / max(wsum, 1e-4)); +} diff --git a/assets/shaders/terrain.frag b/assets/shaders/terrain.frag new file mode 100644 index 0000000..a4de1fd --- /dev/null +++ b/assets/shaders/terrain.frag @@ -0,0 +1,96 @@ +#version 450 core +#include "common.glsl" +#include "pixelart.glsl" + +in VS { + vec3 worldPos; + vec3 normal; + vec2 uv; + flat float layer; + float ao; + flat float kind; +} v; + +layout(binding = 0) uniform sampler2DArray uTerrainTex; +layout(binding = 1) uniform usampler2D uMatMap; +uniform ivec2 uMapSize; +uniform ivec4 uMatTop[8]; // topFirst, topCount, accentFirst, accentCount +uniform float uMatAccent[8]; // accent chance + +layout(location = 0) out vec4 fragColor; + +// Picks the material of the pixel-art texel at `wp`. Tile borders are jittered +// with low-frequency noise plus chunky per-4px offsets so transitions look +// hand-drawn instead of grid aligned. +float topLayer(vec3 wp) { + float texels = float(textureSize(uTerrainTex, 0).x); + vec2 sp = (floor(wp.xz * texels) + 0.5) / texels; + vec2 j = vec2(vnoise(sp * 1.7), vnoise(sp * 1.7 + vec2(31.7, 11.3))) - 0.5; + vec2 chunk = floor(sp * texels / 4.0); + j += (vec2(hash12(chunk), hash12(chunk + 7.7)) - 0.5) * 0.3; + ivec2 cell = clamp(ivec2(floor(sp + j * 0.55)), ivec2(0), uMapSize - 1); + uint m = min(texelFetch(uMatMap, cell, 0).r, 7u); + ivec4 mt = uMatTop[m]; + vec2 c = vec2(cell); + int layer = mt.x + int(hash12(c + 0.37) * float(mt.y)) % max(mt.y, 1); + if (mt.w > 0 && hash12(c * 1.37 + 5.1) < uMatAccent[m]) + layer = mt.z + int(hash12(c + 9.3) * float(mt.w)) % mt.w; + return float(layer); +} + +void main() { + vec3 wp = v.worldPos; + vec3 n = normalize(v.normal); + vec4 albedo; + if (v.kind < 0.5) { + albedo = sharpSampleArray(uTerrainTex, wp.xz, topLayer(wp)); + } else { + albedo = sharpSampleArray(uTerrainTex, v.uv, v.layer); + if (v.kind > 1.5) { + // Ragged overhang strip (e.g. grass hanging over a cliff edge). + float texels = float(textureSize(uTerrainTex, 0).x); + float px = floor(v.uv.x * texels); + float lip = 2.0 + floor(hash12(vec2(px, v.layer)) * 2.5) + floor(vnoise(vec2(px * 0.3, 3.0)) * 3.0); + float row = v.uv.y * texels; + if (row < lip) { + albedo = sharpSampleArray(uTerrainTex, vec2(v.uv.x, wp.y), v.kind - 2.0); + albedo.rgb *= mix(1.0, 0.7, row / lip); + } else if (row < lip + 1.0) { + albedo.rgb *= 0.55; // dark contact line under the lip + } + } + } + + vec3 L = uSunDir.xyz; + float ndl = max(dot(n, L), 0.0); + float vis = sunVisibility(wp + n * 0.03); + vec3 sun = uSunColor.rgb * uSunDir.w * ndl * vis; + // Camera-side fill keeps cliff faces readable when the sun is behind them. + vec3 toCam = cross(uBillboardRight.xyz, vec3(0.0, 1.0, 0.0)); + vec3 amb = (hemiAmbient(n) + uSkyHorizon.rgb * uSkyHorizon.w * max(dot(n, toCam), 0.0)) * v.ao; + float gao = groundAO(wp, v.kind < 0.5 ? 0.0 : 0.6); + vec3 sheen = vec3(0.0); + vec3 base = albedo.rgb; + if (uWeather.y > 0.0 && wp.y > uWaterParams.x) + base = wetSurface(base, n, normalize(uCamPos.xyz - wp), vis, sheen); + // Contact AO dims ambient fully and direct light partially (reads as a soft contact shadow). + vec3 col = base * (sun * mix(1.0, gao, 0.7) + amb * gao + pointLights(wp, n)) + sheen * gao; + + // Caustics on underwater surfaces, projected along the sun direction. + if (uWaterParams.z > 0.5 && wp.y < uWaterParams.x) { + float depth = uWaterParams.x - wp.y; + vec2 cp = wp.xz + L.xz / max(L.y, 0.2) * depth; + float c = caustics(cp * 0.33, uTime * 0.55); + float vSurf = sunVisibility(wp + L * (depth / max(L.y, 0.2))); + col += albedo.rgb * uSunColor.rgb * uSunDir.w * c * uWaterParams.y * vSurf * + exp(-depth * 0.7) * max(n.y, 0.35); + } + + // Soft contact shadow under the player sprite. + if (uBlob.w > 0.0) { + float d = length(wp.xz - uBlob.xz) / uBlob.w; + float onFloor = 1.0 - smoothstep(0.2, 0.6, abs(wp.y - uBlob.y)); + col *= 1.0 - 0.5 * (1.0 - smoothstep(0.3, 1.0, d)) * onFloor; + } + fragColor = vec4(col, 1.0); +} diff --git a/assets/shaders/terrain.vert b/assets/shaders/terrain.vert new file mode 100644 index 0000000..590d710 --- /dev/null +++ b/assets/shaders/terrain.vert @@ -0,0 +1,27 @@ +#version 450 core +#include "common.glsl" + +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec3 aNormal; +layout(location = 2) in vec2 aUV; +layout(location = 3) in vec3 aMisc; // layer, ao, kind + +out VS { + vec3 worldPos; + vec3 normal; + vec2 uv; + flat float layer; + float ao; + flat float kind; +} o; + +void main() { + o.worldPos = aPos; + o.normal = aNormal; + o.uv = aUV; + o.layer = aMisc.x; + o.ao = aMisc.y; + o.kind = aMisc.z; + gl_Position = uViewProj * vec4(aPos, 1.0); + gl_ClipDistance[0] = dot(vec4(aPos, 1.0), uClipPlane); +} diff --git a/assets/shaders/tonemap.frag b/assets/shaders/tonemap.frag new file mode 100644 index 0000000..c261bfa --- /dev/null +++ b/assets/shaders/tonemap.frag @@ -0,0 +1,63 @@ +#version 450 core +// Bloom mix, color grading, ACES tonemap, vignette, chromatic aberration, grain. + +in vec2 vUV; +layout(binding = 0) uniform sampler2D uHdr; +layout(binding = 1) uniform sampler2D uBloom; + +uniform float uBloomStrength; +uniform float uBloomNorm; +uniform float uExposure; +uniform float uContrast; +uniform float uSaturation; +uniform vec3 uLift; +uniform vec3 uGain; +uniform float uVignette; +uniform float uChromatic; +uniform float uGrain; +uniform float uTimeSeed; + +layout(location = 0) out vec4 fragColor; + +vec3 acesFitted(vec3 x) { + const mat3 inM = mat3(0.59719, 0.07600, 0.02840, 0.35458, 0.90834, 0.13383, 0.04823, 0.01566, 0.83777); + const mat3 outM = mat3(1.60475, -0.10208, -0.00327, -0.53108, 1.10813, -0.07276, -0.07367, -0.00605, 1.07602); + vec3 v = inM * x; + vec3 a = v * (v + 0.0245786) - 0.000090537; + vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081; + return clamp(outM * (a / b), 0.0, 1.0); +} + +float hash12(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +void main() { + vec2 dir = vUV - 0.5; + vec3 c; + c.r = texture(uHdr, vUV - dir * uChromatic * 2.0).r; + c.g = texture(uHdr, vUV).g; + c.b = texture(uHdr, vUV + dir * uChromatic * 2.0).b; + vec3 bloom = texture(uBloom, vUV).rgb * uBloomNorm; + c = mix(c, bloom, uBloomStrength); + c *= uExposure; + + // Contrast in log space around middle grey, then saturation. + const float mid = 0.18; + c = exp2((log2(max(c, vec3(1e-6))) - log2(mid)) * uContrast + log2(mid)); + float l = dot(c, vec3(0.2126, 0.7152, 0.0722)); + c = max(mix(vec3(l), c, uSaturation), 0.0); + c *= uGain; + + c = acesFitted(c); + c += uLift * (1.0 - c); + + float v = 1.0 - smoothstep(0.25, 1.1, length(dir * vec2(1.25, 1.0)) * (0.6 + uVignette)); + c *= mix(1.0, v, clamp(uVignette * 1.6, 0.0, 1.0)); + + c = pow(c, vec3(1.0 / 2.2)); + c += (hash12(gl_FragCoord.xy + uTimeSeed) - 0.5) * uGrain; + fragColor = vec4(c, 1.0); +} diff --git a/assets/shaders/volumetric.frag b/assets/shaders/volumetric.frag new file mode 100644 index 0000000..43fa7f5 --- /dev/null +++ b/assets/shaders/volumetric.frag @@ -0,0 +1,66 @@ +#version 450 core +#include "common.glsl" +// Half-resolution ray march through height fog. Each step samples the sun +// shadow map and canopy cookie, producing physically shadowed light shafts +// (god rays). Output: rgb = in-scattered light, a = transmittance. + +in vec2 vUV; +layout(binding = 0) uniform sampler2D uDepth; +layout(location = 0) out vec4 fragColor; + +float phaseHG(float c, float g) { + float g2 = g * g; + return (1.0 - g2) / (4.0 * PI * pow(1.0 + g2 - 2.0 * g * c, 1.5)); +} + +float fogDensity(vec3 p) { + float h = exp(-max(p.y - uFogParams.y, 0.0) * uFogParams.x); + vec3 drift = vec3(uWind.x, 0.05, uWind.y) * uTime * 0.35; + float n = vnoise3(p * 0.16 + drift) * 0.65 + vnoise3(p * 0.45 - drift * 1.7) * 0.35; + n = mix(1.0, smoothstep(0.2, 0.8, n) * 1.8, uFogParams.z); + return uFogColor.w * h * n; +} + +void main() { + float d = texture(uDepth, vUV).r; + vec3 wp = worldFromDepth(vUV, d); + vec3 ro = uCamPos.xyz; + vec3 rd = wp - ro; + float dist = length(rd); + rd /= dist; + dist = min(dist, uFogParams2.y); + + int steps = int(uFogParams2.z); + float stepLen = dist / float(steps); + float jitter = ign(gl_FragCoord.xy); + + float cosT = dot(rd, uSunDir.xyz); + // Dual lobe: strong forward scattering + a little back/side so shafts + // still read when looking away from the sun. Normalized: isotropic = 1. + float phase = (0.75 * phaseHG(cosT, uFogParams.w) + 0.25 * phaseHG(cosT, -0.25)) * 4.0 * PI; + vec3 sunL = uSunColor.rgb * uSunDir.w * uFogParams2.x * phase; + vec3 ambL = uFogColor.rgb * uSkyZenith.w; + + vec3 scatter = vec3(0.0); + float T = 1.0; + for (int i = 0; i < steps; ++i) { + vec3 p = ro + rd * ((float(i) + jitter) * stepLen); + float dens = fogDensity(p); + if (dens < 1e-5) continue; + // Sparse beam mask: most sun scattering happens inside distinct shafts. + float vis = sunShadowFast(p) * shaftCookie(p); + vec3 S = (sunL * vis + ambL) * dens; + for (int l = 0; l < uLightCount.y; ++l) { // warm haze around fires + vec3 dl = uLightPos[l].xyz - p; + float r = uLightPos[l].w; + float att = max(1.0 - dot(dl, dl) / (r * r), 0.0); + att *= att; + S += uLightColor[l].rgb * att * att * dens * 0.35; + } + float Ts = exp(-dens * stepLen); + scatter += T * S * (1.0 - Ts) / dens; // energy-conserving step integral + T *= Ts; + if (T < 0.01) break; + } + fragColor = vec4(scatter, T); +} diff --git a/assets/shaders/water.frag b/assets/shaders/water.frag new file mode 100644 index 0000000..389ae6d --- /dev/null +++ b/assets/shaders/water.frag @@ -0,0 +1,177 @@ +#version 450 core +#include "common.glsl" + +in vec3 vWorld; + +layout(binding = 0) uniform sampler2D uReflection; +layout(binding = 1) uniform sampler2D uSceneColor; +layout(binding = 2) uniform sampler2D uSceneDepth; +layout(binding = 3) uniform sampler2D uShoreDist; + +uniform vec4 uShoreRect; // minX, minZ, 1/sizeX, 1/sizeZ +uniform vec3 uShallow; +uniform vec3 uDeep; +uniform vec3 uAbsorb; +uniform float uClarity; +uniform float uWaveScale; +uniform float uWaveStrength; +uniform float uWaveSpeed; +uniform float uRefraction; +uniform float uReflDistort; +uniform float uSpecular; +uniform float uGlitter; +uniform float uFoamWidth; +uniform float uFoamIntensity; +uniform int uHasReflection; + +layout(location = 0) out vec4 fragColor; + +// Value noise with analytic derivatives (iq). +vec3 noised(vec2 x) { + vec2 i = floor(x), f = fract(x); + vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); + vec2 du = 30.0 * f * f * (f * (f - 2.0) + 1.0); + float a = hash12(i), b = hash12(i + vec2(1, 0)), c = hash12(i + vec2(0, 1)), d = hash12(i + vec2(1, 1)); + float k1 = b - a, k2 = c - a, k4 = a - b - c + d; + return vec3(a + k1 * u.x + k2 * u.y + k4 * u.x * u.y, du * vec2(k1 + k4 * u.y, k2 + k4 * u.x)); +} + +// Twinkling point highlights: one wandering point per cell, each with its own +// blink rhythm. `r` is the point radius in cell units; it never shrinks below +// ~1.5 px so sparkles stay stable instead of aliasing. +float glitter(vec2 p, float t, float r) { + vec2 cell = floor(p), f = fract(p); + float px = fwidth(p.x) * 1.5; + float rr = max(r, px); + float energy = r * r / (rr * rr); // keep brightness constant when widened + float s = 0.0; + for (int y = -1; y <= 1; ++y) + for (int x = -1; x <= 1; ++x) { + vec2 id = cell + vec2(x, y); + vec2 h = vec2(hash12(id), hash12(id + 19.19)); + vec2 pt = vec2(x, y) + 0.5 + 0.35 * sin(t * (0.4 + h * 0.6) + h.yx * 6.2832); + float blink = pow(max(sin(t * (1.5 + 3.0 * h.x) + h.y * 40.0), 0.0), 6.0); + s += (1.0 - smoothstep(rr * 0.4, rr, length(f - pt))) * blink; + } + return s * energy; +} + +// Expanding rings from raindrops: one drop per cell per cycle, random phase. +// Returns the surface gradient contribution. +vec2 rainRipples(vec2 p, float t) { + vec2 cell = floor(p), f = fract(p); + vec2 grad = vec2(0.0); + for (int y = -1; y <= 1; ++y) + for (int x = -1; x <= 1; ++x) { + vec2 id = cell + vec2(x, y); + vec2 c = vec2(x, y) + vec2(hash12(id), hash12(id + 5.3)) * 0.8 + 0.1; + float phase = fract(t * 1.3 + hash12(id + 11.1)); + vec2 d = f - c; + float r = length(d); + float front = phase * 0.9; // ring radius in cell units + float ring = sin((r - front) * 38.0) * exp(-(r - front) * (r - front) * 120.0) * (1.0 - phase); + grad += (r > 1e-3 ? d / r : vec2(0.0)) * ring; + } + return grad; +} + +// Returns (normal.xyz, normalized height) of layered drifting wavelets. +vec4 waterSurface(vec2 p, float t, float shoreDist) { + vec2 rain = uWeather.x > 0.0 + ? (rainRipples(p * 2.4, t) + rainRipples(p * 3.7 + 9.0, t * 1.17)) * 0.3 * uWeather.x + : vec2(0.0); + vec2 grad = vec2(0.0); + float h = 0.0, amp = 1.0, freq = uWaveScale, norm = 0.0; + const mat2 rot = mat2(0.8, -0.6, 0.6, 0.8); + vec2 dir = vec2(1.0, 0.35); + for (int i = 0; i < 5; ++i) { + vec3 n = noised(p * freq + dir * t * uWaveSpeed * (1.0 + float(i) * 0.4)); + h += n.x * amp; + norm += amp; + grad += n.yz * amp * freq; + p = rot * p; + dir = rot * dir; + amp *= 0.5; + freq *= 1.85; + } + grad *= uWaveStrength * 0.09; + // Ripples rolling in toward the shore. + float ring = sin(shoreDist * 9.0 + t * 2.4) * exp(-shoreDist * 1.6) * 0.06 * uWaveStrength; + grad += vec2(ring) + rain; + return vec4(normalize(vec3(-grad.x, 1.0, -grad.y)), h / norm); +} + +void main() { + vec3 wp = vWorld; + vec3 V = normalize(uCamPos.xyz - wp); + float t = uTime; + vec2 suv = gl_FragCoord.xy * uScreen.zw; + float shoreDist = texture(uShoreDist, (wp.xz - uShoreRect.xy) * uShoreRect.zw).r; + vec4 surf = waterSurface(wp.xz, t, shoreDist); + vec3 n = surf.xyz; + + // Bottom under this pixel. + vec3 sceneP = worldFromDepth(suv, texture(uSceneDepth, suv).r); + float depthV = max(wp.y - sceneP.y, 0.0); + + // Refraction: offset by the surface normal, scaled down in the shallows. + vec2 ruv = suv + n.xz * uRefraction * clamp(depthV * 2.5, 0.0, 1.0); + vec3 rP = worldFromDepth(ruv, texture(uSceneDepth, ruv).r); + if (rP.y > wp.y + 0.01) { // refracted sample is above water: fall back + ruv = suv; + rP = sceneP; + } + vec3 refr = texture(uSceneColor, ruv).rgb; + float rDepth = max(wp.y - rP.y, 0.0); + float path = length(rP - wp) + rDepth; + vec3 transmittance = exp(-uAbsorb * path / max(uClarity, 0.05)); + + float sunVis = sunVisibility(wp); + vec3 L = uSunDir.xyz; + vec3 sunRad = uSunColor.rgb * uSunDir.w; + vec3 lightIn = sunRad * max(L.y, 0.0) * mix(0.35, 1.0, sunVis) + hemiAmbient(vec3(0, 1, 0)); + vec3 body = mix(uShallow, uDeep, 1.0 - exp(-rDepth * 0.8)) * lightIn * 0.65; + vec3 under = refr * transmittance + body * (1.0 - transmittance); + + // Reflection: planar reflection texture shares screen mapping with the main view. + vec3 R = reflect(-V, n); + vec3 refl = uHasReflection == 1 ? texture(uReflection, suv + n.xz * uReflDistort).rgb : skyColor(R); + refl *= mix(vec3(1.0), normalize(uShallow + 1e-3) * 1.6, 0.35); // water tints what it reflects + float ndv = max(dot(n, V), 0.0); + float F = 0.02 + 0.98 * pow(1.0 - ndv, 5.0); + F = mix(F, 1.0, 0.06); // a hint of reflection even looking straight down + vec3 col = mix(under, refl, F); + + // Sky-lit wave crests: stylized bright streaks that keep the surface alive + // even when the sun's reflection is out of view. + float crest = smoothstep(0.58, 0.78, surf.w); + col += skyColor(R) * crest * 0.10 * (0.5 + 0.5 * sunVis); + + // Sun glints: tight core + wide sheen (HDR, drives bloom). Shadowed water + // still catches some sky glints so sparkles don't vanish under trees. + vec3 H = normalize(L + V); + float nh = max(dot(n, H), 0.0); + float specVis = mix(0.15, 1.0, sunVis); + float spec = pow(nh, 1200.0) * 120.0 + pow(nh, 160.0) * 4.0 + pow(nh, 24.0) * 0.35; + col += sunRad * spec * uSpecular * 0.05 * specVis; + + // Glitter: a field of twinkling point highlights on wave facets, two scales. + // Each is a few pixels wide and very bright, so bloom and depth of field + // turn them into the soft bokeh sparkles typical of HD-2D water. + float lobe = 0.35 + pow(nh, 8.0) * 3.0; + float g = glitter(wp.xz * 3.5, t, 0.13) + glitter(wp.xz * 1.6 + 17.0, t * 0.8, 0.085) * 1.8; + g *= smoothstep(0.3, 0.7, surf.w) * (0.5 + 4.0 * F); + col += sunRad * g * lobe * uGlitter * 8.0 * specVis * max(L.y, 0.25); + + // Shoreline foam: broken bands drifting along the distance-to-shore field. + float fn = vnoise(wp.xz * 4.0 + vec2(t * 0.35, -t * 0.25)) * 0.6 + vnoise(wp.xz * 9.0 - t * 0.5) * 0.4; + float d = shoreDist + (fn - 0.5) * 0.12; + float edge = 1.0 - smoothstep(0.0, uFoamWidth * 0.3, d); // solid contact line + float bands = sin(d / max(uFoamWidth, 1e-3) * 9.0 - t * 1.8) * 0.5 + 0.5; // receding wash + float wash = (1.0 - smoothstep(0.0, uFoamWidth, d)) * smoothstep(0.55, 0.85, bands * 0.6 + fn * 0.5); + float foam = clamp(max(edge, wash) * uFoamIntensity, 0.0, 1.0); + vec3 foamCol = vec3(0.92, 0.96, 1.0) * (sunRad * max(L.y, 0.0) * mix(0.4, 1.0, sunVis) + hemiAmbient(vec3(0, 1, 0))) * 0.55; + col = mix(col, foamCol, foam); + + fragColor = vec4(col, 1.0); +} diff --git a/assets/shaders/water.vert b/assets/shaders/water.vert new file mode 100644 index 0000000..14682d7 --- /dev/null +++ b/assets/shaders/water.vert @@ -0,0 +1,16 @@ +#version 450 core +#include "common.glsl" + +layout(location = 0) in vec3 aPos; +uniform float uWaveStrength; + +out vec3 vWorld; + +void main() { + vec3 p = aPos; + // Gentle swell; fine detail comes from per-pixel normals. + p.y += (sin(p.x * 1.3 + uTime * 1.1) * cos(p.z * 1.1 + uTime * 0.9) + + sin(p.x * 0.7 - p.z * 0.9 + uTime * 0.6) * 0.5) * 0.018 * uWaveStrength; + vWorld = p; + gl_Position = uViewProj * vec4(p, 1.0); +} diff --git a/assets/sprites/test_sprite.png b/assets/sprites/test_sprite.png new file mode 100644 index 0000000..15d2255 Binary files /dev/null and b/assets/sprites/test_sprite.png differ diff --git a/assets/textures/flowers.png b/assets/textures/flowers.png new file mode 100644 index 0000000..647ba68 Binary files /dev/null and b/assets/textures/flowers.png differ diff --git a/assets/textures/grass_tuft.png b/assets/textures/grass_tuft.png new file mode 100644 index 0000000..14f8904 Binary files /dev/null and b/assets/textures/grass_tuft.png differ diff --git a/assets/textures/ground_extra.png b/assets/textures/ground_extra.png new file mode 100644 index 0000000..ffa51bb Binary files /dev/null and b/assets/textures/ground_extra.png differ diff --git a/assets/textures/ground_extra_0.png b/assets/textures/ground_extra_0.png new file mode 100644 index 0000000..ffa51bb Binary files /dev/null and b/assets/textures/ground_extra_0.png differ diff --git a/assets/textures/ground_extra_1.png b/assets/textures/ground_extra_1.png new file mode 100644 index 0000000..c75520c Binary files /dev/null and b/assets/textures/ground_extra_1.png differ diff --git a/assets/textures/ground_extra_10.png b/assets/textures/ground_extra_10.png new file mode 100644 index 0000000..3db62eb Binary files /dev/null and b/assets/textures/ground_extra_10.png differ diff --git a/assets/textures/ground_extra_11.png b/assets/textures/ground_extra_11.png new file mode 100644 index 0000000..bc960a0 Binary files /dev/null and b/assets/textures/ground_extra_11.png differ diff --git a/assets/textures/ground_extra_12.png b/assets/textures/ground_extra_12.png new file mode 100644 index 0000000..4b58957 Binary files /dev/null and b/assets/textures/ground_extra_12.png differ diff --git a/assets/textures/ground_extra_13.png b/assets/textures/ground_extra_13.png new file mode 100644 index 0000000..d8d57c7 Binary files /dev/null and b/assets/textures/ground_extra_13.png differ diff --git a/assets/textures/ground_extra_14.png b/assets/textures/ground_extra_14.png new file mode 100644 index 0000000..ccb86d9 Binary files /dev/null and b/assets/textures/ground_extra_14.png differ diff --git a/assets/textures/ground_extra_15.png b/assets/textures/ground_extra_15.png new file mode 100644 index 0000000..71eeabd Binary files /dev/null and b/assets/textures/ground_extra_15.png differ diff --git a/assets/textures/ground_extra_2.png b/assets/textures/ground_extra_2.png new file mode 100644 index 0000000..457923f Binary files /dev/null and b/assets/textures/ground_extra_2.png differ diff --git a/assets/textures/ground_extra_3.png b/assets/textures/ground_extra_3.png new file mode 100644 index 0000000..117a79a Binary files /dev/null and b/assets/textures/ground_extra_3.png differ diff --git a/assets/textures/ground_extra_4.png b/assets/textures/ground_extra_4.png new file mode 100644 index 0000000..a2db018 Binary files /dev/null and b/assets/textures/ground_extra_4.png differ diff --git a/assets/textures/ground_extra_5.png b/assets/textures/ground_extra_5.png new file mode 100644 index 0000000..35beac2 Binary files /dev/null and b/assets/textures/ground_extra_5.png differ diff --git a/assets/textures/ground_extra_6.png b/assets/textures/ground_extra_6.png new file mode 100644 index 0000000..5f10174 Binary files /dev/null and b/assets/textures/ground_extra_6.png differ diff --git a/assets/textures/ground_extra_7.png b/assets/textures/ground_extra_7.png new file mode 100644 index 0000000..aa61a2f Binary files /dev/null and b/assets/textures/ground_extra_7.png differ diff --git a/assets/textures/ground_extra_8.png b/assets/textures/ground_extra_8.png new file mode 100644 index 0000000..9f0e285 Binary files /dev/null and b/assets/textures/ground_extra_8.png differ diff --git a/assets/textures/ground_extra_9.png b/assets/textures/ground_extra_9.png new file mode 100644 index 0000000..e686560 Binary files /dev/null and b/assets/textures/ground_extra_9.png differ diff --git a/assets/textures/ground_grass.png b/assets/textures/ground_grass.png new file mode 100644 index 0000000..8005a8c Binary files /dev/null and b/assets/textures/ground_grass.png differ diff --git a/assets/textures/ground_grass_0.png b/assets/textures/ground_grass_0.png new file mode 100644 index 0000000..8005a8c Binary files /dev/null and b/assets/textures/ground_grass_0.png differ diff --git a/assets/textures/ground_grass_1.png b/assets/textures/ground_grass_1.png new file mode 100644 index 0000000..4d36bd9 Binary files /dev/null and b/assets/textures/ground_grass_1.png differ diff --git a/assets/textures/ground_grass_10.png b/assets/textures/ground_grass_10.png new file mode 100644 index 0000000..607bc7e Binary files /dev/null and b/assets/textures/ground_grass_10.png differ diff --git a/assets/textures/ground_grass_11.png b/assets/textures/ground_grass_11.png new file mode 100644 index 0000000..b5b33d3 Binary files /dev/null and b/assets/textures/ground_grass_11.png differ diff --git a/assets/textures/ground_grass_12.png b/assets/textures/ground_grass_12.png new file mode 100644 index 0000000..fac7d61 Binary files /dev/null and b/assets/textures/ground_grass_12.png differ diff --git a/assets/textures/ground_grass_13.png b/assets/textures/ground_grass_13.png new file mode 100644 index 0000000..6f2476f Binary files /dev/null and b/assets/textures/ground_grass_13.png differ diff --git a/assets/textures/ground_grass_14.png b/assets/textures/ground_grass_14.png new file mode 100644 index 0000000..535bb0e Binary files /dev/null and b/assets/textures/ground_grass_14.png differ diff --git a/assets/textures/ground_grass_15.png b/assets/textures/ground_grass_15.png new file mode 100644 index 0000000..8440b44 Binary files /dev/null and b/assets/textures/ground_grass_15.png differ diff --git a/assets/textures/ground_grass_2.png b/assets/textures/ground_grass_2.png new file mode 100644 index 0000000..dbcad96 Binary files /dev/null and b/assets/textures/ground_grass_2.png differ diff --git a/assets/textures/ground_grass_3.png b/assets/textures/ground_grass_3.png new file mode 100644 index 0000000..6df7c67 Binary files /dev/null and b/assets/textures/ground_grass_3.png differ diff --git a/assets/textures/ground_grass_4.png b/assets/textures/ground_grass_4.png new file mode 100644 index 0000000..b4ea826 Binary files /dev/null and b/assets/textures/ground_grass_4.png differ diff --git a/assets/textures/ground_grass_5.png b/assets/textures/ground_grass_5.png new file mode 100644 index 0000000..da37af1 Binary files /dev/null and b/assets/textures/ground_grass_5.png differ diff --git a/assets/textures/ground_grass_6.png b/assets/textures/ground_grass_6.png new file mode 100644 index 0000000..49e553e Binary files /dev/null and b/assets/textures/ground_grass_6.png differ diff --git a/assets/textures/ground_grass_7.png b/assets/textures/ground_grass_7.png new file mode 100644 index 0000000..10cfe4e Binary files /dev/null and b/assets/textures/ground_grass_7.png differ diff --git a/assets/textures/ground_grass_8.png b/assets/textures/ground_grass_8.png new file mode 100644 index 0000000..655caab Binary files /dev/null and b/assets/textures/ground_grass_8.png differ diff --git a/assets/textures/ground_grass_9.png b/assets/textures/ground_grass_9.png new file mode 100644 index 0000000..4c0f583 Binary files /dev/null and b/assets/textures/ground_grass_9.png differ diff --git a/assets/textures/ground_misc.png b/assets/textures/ground_misc.png new file mode 100644 index 0000000..0a42c0c Binary files /dev/null and b/assets/textures/ground_misc.png differ diff --git a/assets/textures/ground_misc_0.png b/assets/textures/ground_misc_0.png new file mode 100644 index 0000000..0a42c0c Binary files /dev/null and b/assets/textures/ground_misc_0.png differ diff --git a/assets/textures/ground_misc_1.png b/assets/textures/ground_misc_1.png new file mode 100644 index 0000000..114a232 Binary files /dev/null and b/assets/textures/ground_misc_1.png differ diff --git a/assets/textures/ground_misc_10.png b/assets/textures/ground_misc_10.png new file mode 100644 index 0000000..b8a1e7a Binary files /dev/null and b/assets/textures/ground_misc_10.png differ diff --git a/assets/textures/ground_misc_11.png b/assets/textures/ground_misc_11.png new file mode 100644 index 0000000..4c02dff Binary files /dev/null and b/assets/textures/ground_misc_11.png differ diff --git a/assets/textures/ground_misc_12.png b/assets/textures/ground_misc_12.png new file mode 100644 index 0000000..c508c9d Binary files /dev/null and b/assets/textures/ground_misc_12.png differ diff --git a/assets/textures/ground_misc_13.png b/assets/textures/ground_misc_13.png new file mode 100644 index 0000000..1058449 Binary files /dev/null and b/assets/textures/ground_misc_13.png differ diff --git a/assets/textures/ground_misc_14.png b/assets/textures/ground_misc_14.png new file mode 100644 index 0000000..f1f123d Binary files /dev/null and b/assets/textures/ground_misc_14.png differ diff --git a/assets/textures/ground_misc_15.png b/assets/textures/ground_misc_15.png new file mode 100644 index 0000000..f9bfcba Binary files /dev/null and b/assets/textures/ground_misc_15.png differ diff --git a/assets/textures/ground_misc_2.png b/assets/textures/ground_misc_2.png new file mode 100644 index 0000000..f8a3e7a Binary files /dev/null and b/assets/textures/ground_misc_2.png differ diff --git a/assets/textures/ground_misc_3.png b/assets/textures/ground_misc_3.png new file mode 100644 index 0000000..015701b Binary files /dev/null and b/assets/textures/ground_misc_3.png differ diff --git a/assets/textures/ground_misc_4.png b/assets/textures/ground_misc_4.png new file mode 100644 index 0000000..7188a15 Binary files /dev/null and b/assets/textures/ground_misc_4.png differ diff --git a/assets/textures/ground_misc_5.png b/assets/textures/ground_misc_5.png new file mode 100644 index 0000000..5b72422 Binary files /dev/null and b/assets/textures/ground_misc_5.png differ diff --git a/assets/textures/ground_misc_6.png b/assets/textures/ground_misc_6.png new file mode 100644 index 0000000..9767134 Binary files /dev/null and b/assets/textures/ground_misc_6.png differ diff --git a/assets/textures/ground_misc_7.png b/assets/textures/ground_misc_7.png new file mode 100644 index 0000000..0c80907 Binary files /dev/null and b/assets/textures/ground_misc_7.png differ diff --git a/assets/textures/ground_misc_8.png b/assets/textures/ground_misc_8.png new file mode 100644 index 0000000..a2e87d9 Binary files /dev/null and b/assets/textures/ground_misc_8.png differ diff --git a/assets/textures/ground_misc_9.png b/assets/textures/ground_misc_9.png new file mode 100644 index 0000000..6332eb4 Binary files /dev/null and b/assets/textures/ground_misc_9.png differ diff --git a/assets/textures/tall_grass.png b/assets/textures/tall_grass.png new file mode 100644 index 0000000..6a5b094 Binary files /dev/null and b/assets/textures/tall_grass.png differ diff --git a/demo/DemoScene.cpp b/demo/DemoScene.cpp new file mode 100644 index 0000000..eb4c914 --- /dev/null +++ b/demo/DemoScene.cpp @@ -0,0 +1,398 @@ +#include "DemoScene.h" + +#include "hd2d/Log.h" + +#include +#include + +using namespace hd2d; + +namespace { + +constexpr int kW = 64, kD = 64; +constexpr float kWaterLevel = -0.2f; +constexpr float kPixelsPerUnit = 32.0f; // world texel density shared by all pixel art + +enum Mat : uint8_t { Grass = 0, Meadow = 1, Path = 2, Sand = 3, Stone = 4, Ridge = 5 }; + +// Terrain texture array layers (see tools/assets.json for the sources). +const char* kLayers[] = { + "ground_grass_0", "ground_grass_1", "ground_grass_2", "ground_grass_3", // 0-3 plain grass + "ground_grass_4", "ground_grass_5", "ground_grass_8", // 4-6 flowers / clover + "ground_grass_12", "ground_grass_13", // 7-8 dark meadow grass + "ground_extra_1", // 9 earth path + "ground_extra_4", "ground_extra_5", // 10-11 earth cliff + "ground_misc_4", "ground_misc_5", // 12-13 mossy stone cliff + "ground_extra_8", "ground_extra_9", // 14-15 sand + "ground_misc_12", "ground_misc_13", // 16-17 cobblestone +}; + +float ridgeLow(float x) { return 16.0f + 2.5f * std::sin(x * 0.21f) + 1.5f * std::sin(x * 0.53f + 1.3f); } +float ridgeHigh(float x) { return 8.0f + 2.0f * std::sin(x * 0.17f + 2.0f) + 1.2f * std::sin(x * 0.61f); } + +// Distance from a wobbly blob's center, normalized so the edge sits at `radius`. +float blob(glm::vec2 p, glm::vec2 c, float radius, float seed) { + glm::vec2 d = p - c; + float a = std::atan2(d.y, d.x); + float wobble = 1.0f + 0.16f * std::sin(a * 3.0f + seed) + 0.08f * std::sin(a * 5.0f + seed * 2.3f); + return glm::length(d) / (radius * wobble); +} + +const glm::vec2 kPondCenter{22.0f, 36.0f}; +const glm::vec2 kClearing{33.5f, 44.5f}; // kept free of trees and rocks +const glm::vec2 kStart{13.0f, 44.0f}; // player start: tall grass beside the pond + +struct Patch { + glm::vec2 c; + float r; +}; +const Patch kTallGrass[] = {{{12, 47}, 5.5f}, {{46, 28}, 4.2f}, {{29, 52}, 3.2f}, {{54, 52}, 4.5f}, {{20, 12}, 3.5f}}; + +// Stone bridge across the pond along +X. Must match BRIDGE_* and deck_z() in +// tools/blender_scenery.py. +const glm::vec2 kBridgeCenter = kPondCenter; +constexpr float kBridgeLen = 17.0f, kBridgeWidth = 2.6f, kBridgeRise = 0.7f, kBridgeWall = 0.28f; + +float bridgeDeck(float localX) { + float u = std::clamp(2.0f * localX / kBridgeLen, -1.0f, 1.0f); + return 0.05f + kBridgeRise * (1.0f - u * u); +} +// Inside the corridor around the bridge (kept free of props), with a margin. +bool nearBridge(glm::vec2 p, float margin) { + glm::vec2 d = glm::abs(p - kBridgeCenter); + return d.x < kBridgeLen * 0.5f + margin && d.y < kBridgeWidth * 0.5f + margin; +} +bool onBridgeDeck(glm::vec2 p) { + glm::vec2 d = glm::abs(p - kBridgeCenter); + return d.x <= kBridgeLen * 0.5f + 0.2f && d.y < kBridgeWidth * 0.5f - kBridgeWall - 0.15f; +} + +} // namespace + +bool DemoScene::load(const std::string& assetDir) { + const std::string tex = assetDir + "/textures/"; + std::vector layers; + for (const char* l : kLayers) layers.push_back(tex + l + ".png"); + if (!terrain_.textures.load(layers)) return false; + + struct { + BillboardBatch* batch; + const char* file; + } sprites[] = {{&tallGrass_, "tall_grass.png"}, {&tufts_, "grass_tuft.png"}, {&flowers_, "flowers.png"}}; + for (auto& s : sprites) + if (!s.batch->loadTexture(tex + s.file)) return false; + if (!player_.loadTexture(assetDir + "/sprites/test_sprite.png")) return false; + + // 3D scenery (built by tools/blender_scenery.py). + if (!models_.load(assetDir + "/models/scenery.glb")) return false; + for (auto& [batch, name] : {std::pair{&oaks_[0], "oak_a"}, {&oaks_[1], "oak_b"}, {&pines_[0], "pine_a"}, + {&pines_[1], "pine_b"}, {&bushes_[0], "bush_a"}, {&bushes_[1], "bush_b"}, + {&rocks_[0], "rock_a"}, {&rocks_[1], "rock_b"}}) { + batch->model = models_.get(name); + if (!batch->model) return false; + } + bridge_.model = models_.get("bridge"); + if (!bridge_.model) return false; + bridge_.instances = {{{kBridgeCenter.x, 0.0f, kBridgeCenter.y}, 0.0f, glm::vec3(1.0f), 1.0f}}; + bridge_.upload(); + // Fires burn in the braziers on the bridge's entrance pillars. + for (const auto& [name, local] : bridge_.model->sockets) + if (name.rfind("fire", 0) == 0) particles_.addFire(bridge_.instances[0].toWorld(local), 0.9f); + + // Contact occlusion under scenery (tree bases, bushes, rocks). + for (ModelBatch& b : oaks_) b.groundOcclusion = 1.0f; + for (ModelBatch& b : pines_) b.groundOcclusion = 1.0f; + for (ModelBatch& b : bushes_) b.groundOcclusion = 0.8f; + for (ModelBatch& b : rocks_) b.groundOcclusion = 0.7f; + + tufts_.inReflections = false; // tiny, never visible in the pond + flowers_.inReflections = false; + player_.emissive = 0.06f; + + buildTerrain(); + scatterProps(); + std::vector occluders; + for (auto* group : {&oaks_, &pines_, &bushes_, &rocks_}) + for (ModelBatch& b : *group) occluders.push_back(&b); + groundAO_.build({0.0f, 0.0f}, {float(kW), float(kD)}, occluders); + + // Pond water plane, slightly larger than the basin. + water_.build(kPondCenter - glm::vec2(9.0f), kPondCenter + glm::vec2(9.0f), kWaterLevel); + water_.buildShoreField(terrain_); + + particles_.addField(ParticleSystem::Kind::Firefly, {4, 0.2f, 26}, {58, 1.8f, 58}, 45); + particles_.addField(ParticleSystem::Kind::Mote, {6, 0.0f, 4}, {58, 7.0f, 60}, 650); + + scene.terrain = &terrain_; + scene.water = &water_; + scene.particles = &particles_; + scene.billboards = {&tallGrass_, &tufts_, &flowers_, &player_}; + scene.models.clear(); + for (auto* group : {&oaks_, &pines_, &bushes_, &rocks_}) + for (ModelBatch& b : *group) scene.models.push_back(&b); + scene.models.push_back(&bridge_); + scene.groundOcclusion = &groundAO_; + scene.volumetricLights = int(particles_.fires().size()); + scene.interactorRadius = 1.0f; + scene.blobShadowRadius = 0.55f; + + player_.instances.resize(1); + placePlayer(kStart); + return true; +} + +void DemoScene::buildTerrain() { + Terrain& t = terrain_; + t.init(kW, kD, 0.5f); + t.baseLevel = -8; + // topFirst, topCount, accentFirst, accentCount, accentChance, sideFirst, sideCount, lipLayer + t.materials[Grass] = {0, 4, 4, 3, 0.10f, 10, 2, 0}; + t.materials[Meadow] = {7, 2, 0, 4, 0.15f, 10, 2, 7}; + t.materials[Path] = {9, 1, 0, 0, 0.0f, 10, 2, -1}; + t.materials[Sand] = {14, 2, 0, 0, 0.0f, 14, 2, -1}; + t.materials[Stone] = {16, 2, 0, 0, 0.0f, 12, 2, -1}; + t.materials[Ridge] = {0, 4, 7, 2, 0.2f, 12, 2, 0}; + + tallGrassMask_.assign(size_t(kW) * kD, 0); + + for (int z = 0; z < kD; ++z) + for (int x = 0; x < kW; ++x) { + glm::vec2 p(float(x) + 0.5f, float(z) + 0.5f); + int level = 0; + uint8_t mat = Grass; + if (p.y < ridgeLow(p.x)) level = 2, mat = Ridge; + if (p.y < ridgeHigh(p.x)) level = 4, mat = Ridge; + + float pond = blob(p, kPondCenter, 6.3f, 0.7f); + if (pond < 1.17f) mat = Sand; // beach ring + if (pond < 1.0f) level = -1; // shallow shelf + if (pond < 0.72f) level = -3; // deep basin + + if (blob(p, {47.0f, 40.0f}, 3.6f, 2.1f) < 1.0f) level = 1; // small mound + + if (level >= 0 && mat != Sand) + for (const Patch& tg : kTallGrass) + if (blob(p, tg.c, tg.r, tg.c.x) < 1.0f) { + mat = Meadow; + tallGrassMask_[size_t(z) * kW + x] = 1; + } + t.setLevel(x, z, level); + t.setMaterial(x, z, mat); + } + + // Winding earth path from the south edge up to the ridge stairs. + const glm::vec2 path[] = {{33, 64}, {33, 56}, {36, 48}, {40, 40}, {41.5f, 31}, {40.5f, 24}, {40.5f, 2}}; + for (size_t i = 0; i + 1 < std::size(path); ++i) { + glm::vec2 a = path[i], b = path[i + 1]; + int steps = int(glm::length(b - a) * 4.0f); + for (int s = 0; s <= steps; ++s) { + glm::vec2 q = glm::mix(a, b, float(s) / float(std::max(steps, 1))); + for (int dz = -2; dz <= 2; ++dz) + for (int dx = -2; dx <= 2; ++dx) { + int x = int(std::floor(q.x)) + dx, z = int(std::floor(q.y)) + dz; + if (!t.inBounds(x, z) || t.level(x, z) < 0) continue; + if (glm::length(glm::vec2(float(x) + 0.5f, float(z) + 0.5f) - q) > 1.25f) continue; + t.setMaterial(x, z, Path); + tallGrassMask_[size_t(z) * kW + x] = 0; + } + } + } + + // Cobblestone stairs where the path climbs each ridge (one level per step). + for (int x = 39; x <= 41; ++x) { + int zLow = int(std::ceil(ridgeLow(float(x) + 0.5f) - 0.5f)); // first level-0 tile south of ridge + int zHigh = int(std::ceil(ridgeHigh(float(x) + 0.5f) - 0.5f)); // first level-2 tile south of top + for (int z = zHigh; z < zLow; ++z) t.setMaterial(x, z, Path); + t.setLevel(x, zLow, 1); + t.setMaterial(x, zLow, Stone); + t.setLevel(x, zHigh, 3); + t.setMaterial(x, zHigh, Stone); + } + t.build(); +} + +bool DemoScene::occupied(glm::vec2 p, float r) const { + for (const Obstacle& o : obstacles_) + if (glm::length(o.pos - p) < o.radius + r) return true; + return false; +} + +void DemoScene::scatterProps() { + std::uniform_real_distribution u(0.0f, 1.0f); + const Terrain& t = terrain_; + auto tint = [&] { return 0.86f + 0.22f * u(rng_); }; + auto groundOk = [&](int x, int z) { + uint8_t m = t.material(x, z); + return t.level(x, z) >= 0 && m != Path && m != Sand && m != Stone; + }; + + // Trees: dense forest band at the diorama border, sparse elsewhere. + for (int z = 0; z < kD; ++z) + for (int x = 0; x < kW; ++x) { + if (!groundOk(x, z) || tallGrassMask_[size_t(z) * kW + x]) continue; + int edge = std::min({x, z, kW - 1 - x, kD - 1 - z}); + float chance = edge < 6 ? 0.45f - float(edge) * 0.05f : (t.level(x, z) >= 4 ? 0.14f : 0.012f); + if (u(rng_) > chance) continue; + glm::vec2 p(float(x) + 0.2f + 0.6f * u(rng_), float(z) + 0.2f + 0.6f * u(rng_)); + if (glm::length(p - kClearing) < 5.0f || nearBridge(p, 2.5f) || occupied(p, 1.3f)) continue; + if (blob(p, kPondCenter, 6.3f, 0.7f) < 1.35f) continue; + bool pine = u(rng_) < (t.level(x, z) >= 2 || edge < 6 ? 0.55f : 0.25f); + auto& group = pine ? pines_ : oaks_; + float scale = (pine ? 0.75f : 0.8f) + 0.3f * u(rng_); + float squash = 0.92f + 0.16f * u(rng_); // breaks up silhouettes of repeated models + group[u(rng_) < 0.5f ? 0 : 1].instances.push_back( + {{p.x, t.heightAt(p.x, p.y), p.y}, u(rng_) * 6.2832f, glm::vec3(scale, scale * squash, scale), tint()}); + obstacles_.push_back({p, 0.45f * scale}); + } + + // Bushes and rocks (rocks gather around the pond). + for (int z = 1; z < kD - 1; ++z) + for (int x = 1; x < kW - 1; ++x) { + glm::vec2 p(float(x) + 0.5f + 0.4f * (u(rng_) - 0.5f), float(z) + 0.5f + 0.4f * (u(rng_) - 0.5f)); + if (t.level(x, z) < 0 || t.material(x, z) == Path || t.material(x, z) == Stone) continue; + if (glm::length(p - kClearing) < 3.0f || nearBridge(p, 1.0f) || occupied(p, 0.9f)) continue; + float pond = blob(p, kPondCenter, 6.3f, 0.7f); + bool nearPond = pond > 1.0f && pond < 1.3f; + float r = u(rng_); + if (r < (nearPond ? 0.12f : 0.008f)) { + float s = 0.8f + 0.5f * u(rng_); + rocks_[u(rng_) < 0.5f ? 0 : 1].instances.push_back( + {{p.x, t.heightAt(p.x, p.y) - 0.02f, p.y}, u(rng_) * 6.2832f, + glm::vec3(s, s * (0.8f + 0.4f * u(rng_)), s), tint()}); + obstacles_.push_back({p, 0.42f * s}); + } else if (groundOk(x, z) && !tallGrassMask_[size_t(z) * kW + x] && r < (nearPond ? 0.17f : 0.03f)) { + float s = 0.8f + 0.45f * u(rng_); + bushes_[u(rng_) < 0.5f ? 0 : 1].instances.push_back( + {{p.x, t.heightAt(p.x, p.y) - 0.03f, p.y}, u(rng_) * 6.2832f, glm::vec3(s), tint()}); + obstacles_.push_back({p, 0.5f * s}); + } + } + + // Pokemon-style tall grass: dense overlapping rows of swaying clumps. + for (int z = 0; z < kD; ++z) + for (int x = 0; x < kW; ++x) { + if (!tallGrassMask_[size_t(z) * kW + x]) continue; + for (int row = 0; row < 2; ++row) + for (int col = 0; col < 2; ++col) { + glm::vec2 p(float(x) + 0.25f + 0.5f * float(col) + 0.15f * (u(rng_) - 0.5f), + float(z) + 0.3f + 0.5f * float(row) + 0.1f * (u(rng_) - 0.5f)); + if (nearBridge(p, 0.5f)) continue; + float s = 1.0f + 0.2f * u(rng_); + tallGrass_.instances.push_back({{p.x, t.heightAt(p.x, p.y) - 0.03f, p.y}, 1.0f, + glm::vec2(1.1f, 1.1f) * s, u(rng_) < 0.5f ? 1.0f : 0.0f, + tint()}); + } + } + + // Short grass tufts and flowers sprinkled across open grass. + for (int z = 0; z < kD; ++z) + for (int x = 0; x < kW; ++x) { + if (!groundOk(x, z) || tallGrassMask_[size_t(z) * kW + x]) continue; + // Clustered, darker tufts read as texture variation instead of stickers. + float cluster = 0.5f + 0.5f * std::sin(float(x) * 0.35f + 1.7f) * std::sin(float(z) * 0.29f + 0.4f); + int n = int(u(rng_) * (0.3f + 2.6f * cluster * cluster)); + for (int i = 0; i < n; ++i) { + glm::vec2 p(float(x) + u(rng_), float(z) + u(rng_)); + if (nearBridge(p, 0.3f)) continue; + float s = 0.6f + 0.45f * u(rng_); + tufts_.instances.push_back({{p.x, t.heightAt(p.x, p.y) - 0.02f, p.y}, 1.0f, + tufts_.sizeForDensity(kPixelsPerUnit) * s, u(rng_) < 0.5f ? 1.0f : 0.0f, + 0.62f + 0.18f * u(rng_)}); + } + if (u(rng_) < 0.07f) { + glm::vec2 p(float(x) + u(rng_), float(z) + u(rng_)); + if (nearBridge(p, 0.3f)) continue; + flowers_.instances.push_back({{p.x, t.heightAt(p.x, p.y) - 0.02f, p.y}, 0.8f, + flowers_.sizeForDensity(kPixelsPerUnit) * (0.7f + 0.3f * u(rng_)), + u(rng_) < 0.5f ? 1.0f : 0.0f, 1.0f}); + } + } + + for (BillboardBatch* b : {&tallGrass_, &tufts_, &flowers_}) b->upload(); + auto count = [](const std::array& g) { return g[0].instances.size() + g[1].instances.size(); }; + for (auto* group : {&oaks_, &pines_, &bushes_, &rocks_}) + for (ModelBatch& b : *group) b.upload(); + HD2D_LOG("props: %zu oaks, %zu pines, %zu bushes, %zu rocks, %zu tall grass, %zu tufts, %zu flowers", + count(oaks_), count(pines_), count(bushes_), count(rocks_), tallGrass_.instances.size(), + tufts_.instances.size(), flowers_.instances.size()); +} + +bool DemoScene::walkable(glm::vec2 from, glm::vec2 to) const { + const Terrain& t = terrain_; + if (to.x < 1.0f || to.y < 1.0f || to.x > float(kW - 1) || to.y > float(kD - 1)) return false; + bool deckTo = onBridgeDeck(to), deckFrom = onBridgeDeck(from); + // Parapets and entrance pillars block the sides of the deck; the ends stay open. + glm::vec2 rel = glm::abs(to - kBridgeCenter); + if (!deckTo && nearBridge(to, 0.6f) && rel.x < kBridgeLen * 0.5f + 0.3f && + rel.y >= kBridgeWidth * 0.5f - kBridgeWall - 0.15f) + return false; + if (!deckTo) { + int fl = t.level(int(std::floor(from.x)), int(std::floor(from.y))); + int tl = t.level(int(std::floor(to.x)), int(std::floor(to.y))); + if (tl < 0) return false; // pond + if (!deckFrom && (tl > fl + 1 || tl < fl - 2)) return false; // step up one level, drop at most two + } + for (const Obstacle& o : obstacles_) { + float r = o.radius + 0.28f; + if (glm::length(o.pos - to) < r && glm::length(o.pos - to) < glm::length(o.pos - from)) return false; + } + return true; +} + +float DemoScene::groundHeight(glm::vec2 p) const { + return onBridgeDeck(p) ? bridgeDeck(p.x - kBridgeCenter.x) : terrain_.heightAt(p.x, p.y); +} + +void DemoScene::placePlayer(glm::vec2 xz) { playerPos = glm::vec3(xz.x, groundHeight(xz), xz.y); } + +void DemoScene::update(float dt, float time, glm::vec2 move, const Camera& camera) { + glm::vec3 right = camera.right(); + glm::vec3 fwd(right.z, 0.0f, -right.x); // horizontal camera forward + glm::vec3 dir = right * move.x + fwd * move.y; + float amount = std::min(glm::length(dir), 1.0f); + bool moving = amount > 0.01f; + + if (moving) { + dir = glm::normalize(dir) * amount; + glm::vec2 from(playerPos.x, playerPos.z); + glm::vec2 step = glm::vec2(dir.x, dir.z) * playerSpeed_ * dt; + // Axis-separated resolution lets the player slide along obstacles. + glm::vec2 p = from; + if (walkable(p, p + glm::vec2(step.x, 0))) p.x += step.x; + if (walkable(p, p + glm::vec2(0, step.y))) p.y += step.y; + playerPos.x = p.x; + playerPos.z = p.y; + if (std::abs(move.x) > 0.1f) facing_ = move.x > 0.0f ? 1.0f : 0.0f; // sprite looks left by default + walkPhase_ += dt * 13.0f; + } else { + walkPhase_ = 0.0f; + } + float ground = groundHeight({playerPos.x, playerPos.z}); + playerPos.y += (ground - playerPos.y) * std::min(1.0f, dt * 14.0f); + + int tx = int(std::floor(playerPos.x)), tz = int(std::floor(playerPos.z)); + bool inTallGrass = terrain_.inBounds(tx, tz) && tallGrassMask_[size_t(tz) * kW + tx]; + stepTimer_ -= dt; + if (moving && stepTimer_ <= 0.0f) { + stepTimer_ = inTallGrass ? 0.12f : 0.25f; + glm::vec3 feet(playerPos.x, ground + 0.05f, playerPos.z); + if (inTallGrass) particles_.burst(feet + glm::vec3(0, 0.3f, 0), 3, {0.20f, 0.42f, 0.10f}, 1.6f, 0.05f, 0.7f); + else particles_.burst(feet, 2, {0.32f, 0.27f, 0.20f}, 0.7f, 0.06f, 0.5f); + } + + float hop = moving ? std::abs(std::sin(walkPhase_)) * 0.09f : 0.0f; + BillboardInstance& pi = player_.instances[0]; + pi.pos = glm::vec3(playerPos.x, playerPos.y + hop - 0.02f, playerPos.z); + pi.size = player_.sizeForDensity(4.0f * kPixelsPerUnit); // art is 4x upscaled + pi.flip = facing_; + pi.tint = 1.0f; + player_.upload(); + + particles_.update(dt, time); + scene.lights.clear(); + particles_.collectLights(scene.lights, 12); // fires first, then fireflies + + scene.interactor = playerPos; + scene.blobShadowPos = glm::vec3(playerPos.x, ground, playerPos.z); + scene.focusPoint = playerPos + glm::vec3(0, 0.6f, 0); +} diff --git a/demo/DemoScene.h b/demo/DemoScene.h new file mode 100644 index 0000000..7ed73fd --- /dev/null +++ b/demo/DemoScene.h @@ -0,0 +1,50 @@ +#pragma once +#include "hd2d/Camera.h" +#include "hd2d/Scene.h" +#include "hd2d/Weather.h" + +#include +#include +#include +#include +// Meadow diorama: terraced grass, a cliff ridge with steps, a pond crossed by +// a stone bridge with burning braziers, 3D trees/bushes/rocks, weather and a +// WASD-controlled pixel-art player sprite. +class DemoScene { +public: + bool load(const std::string& assetDir); + // `move`: x = screen right, y = screen up (camera-relative), length <= 1. + void update(float dt, float time, glm::vec2 move, const hd2d::Camera& camera); + void placePlayer(glm::vec2 xz); + + hd2d::Scene scene; + glm::vec3 playerPos{0.0f}; + hd2d::Weather weather; // call weather.update() each frame before rendering + +private: + struct Obstacle { + glm::vec2 pos; + float radius; + }; + void buildTerrain(); + void scatterProps(); + bool walkable(glm::vec2 from, glm::vec2 to) const; + bool occupied(glm::vec2 p, float r) const; + float groundHeight(glm::vec2 p) const; // terrain or bridge deck + + hd2d::Terrain terrain_; + hd2d::Water water_; + hd2d::ParticleSystem particles_; + hd2d::BillboardBatch tallGrass_, tufts_, flowers_, player_; // pixel-art sprites + hd2d::ModelLibrary models_; // 3D scenery + std::array oaks_, pines_, bushes_, rocks_; // two model variants each + hd2d::ModelBatch bridge_; + hd2d::GroundOcclusion groundAO_; + std::vector obstacles_; + std::vector tallGrassMask_; + std::mt19937 rng_{42}; + float playerSpeed_ = 4.2f; + float walkPhase_ = 0.0f; + float stepTimer_ = 0.0f; + float facing_ = 0.0f; +}; diff --git a/demo/main.cpp b/demo/main.cpp new file mode 100644 index 0000000..d1d595e --- /dev/null +++ b/demo/main.cpp @@ -0,0 +1,230 @@ +// HD-2D engine demo: meadow diorama with a WASD-controlled sprite. +// +// Controls: WASD/arrows move, Q/E rotate camera, right-drag orbit, wheel zoom, +// F1 toggle tuning panel, F5 hot-reload shaders, F12 screenshot, Esc quit. +// +// Headless-ish capture for testing: +// hd2d_demo --screenshot out.png [--frames 120] [--move x,y] [--pos x,z] +// [--sun azimuthDeg,elevationDeg] [--yaw deg] [--dist units] [--size WxH] [--ui] +// [--set name=value ...] e.g. --set godRays=4 --set dof=0 + +#include "DemoScene.h" +#include "hd2d/DebugUI.h" +#include "hd2d/Log.h" +#include "hd2d/Renderer.h" +#include "hd2d/Screenshot.h" +#include "hd2d/Window.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef HD2D_ASSET_DIR +#define HD2D_ASSET_DIR "assets" +#endif + +namespace { + +struct Options { + std::string screenshot; + int frames = 120; + glm::vec2 move{0.0f}; + bool hasPos = false, hasSun = false, hasYaw = false, ui = false; + std::vector> settings; // --set name=value + glm::vec2 pos{0.0f}, sun{0.0f}; + float yaw = 0.0f, dist = 0.0f, rain = 0.0f; + int width = 1600, height = 900; +}; + +bool parsePair(const char* s, float& a, float& b, char sep = ',') { + char fmt[16]; + std::snprintf(fmt, sizeof fmt, "%%f%c%%f", sep); + return std::sscanf(s, fmt, &a, &b) == 2; +} + +Options parseArgs(int argc, char** argv) { + Options o; + for (int i = 1; i < argc; ++i) { + auto next = [&]() -> const char* { return i + 1 < argc ? argv[++i] : ""; }; + if (!std::strcmp(argv[i], "--screenshot")) o.screenshot = next(); + else if (!std::strcmp(argv[i], "--frames")) o.frames = std::max(1, std::atoi(next())); + else if (!std::strcmp(argv[i], "--move")) parsePair(next(), o.move.x, o.move.y); + else if (!std::strcmp(argv[i], "--pos")) o.hasPos = parsePair(next(), o.pos.x, o.pos.y); + else if (!std::strcmp(argv[i], "--sun")) o.hasSun = parsePair(next(), o.sun.x, o.sun.y); + else if (!std::strcmp(argv[i], "--yaw")) o.hasYaw = true, o.yaw = float(std::atof(next())); + else if (!std::strcmp(argv[i], "--dist")) o.dist = float(std::atof(next())); + else if (!std::strcmp(argv[i], "--ui")) o.ui = true; + else if (!std::strcmp(argv[i], "--rain")) o.rain = float(std::atof(next())); + else if (!std::strcmp(argv[i], "--set")) { + std::string kv = next(); + auto eq = kv.find('='); + if (eq != std::string::npos) o.settings.emplace_back(kv.substr(0, eq), float(std::atof(kv.c_str() + eq + 1))); + } + else if (!std::strcmp(argv[i], "--size")) { + float w, h; + if (parsePair(next(), w, h, 'x')) o.width = int(w), o.height = int(h); + } else std::fprintf(stderr, "unknown argument: %s\n", argv[i]); + } + return o; +} + +// Overrides a renderer setting by name (bools take 0/1). Returns false if unknown. +bool applySetting(hd2d::RenderSettings& s, const std::string& name, float v) { + struct Entry { + const char* name; + float* f; + bool* b; + } table[] = { + {"volumetrics", nullptr, &s.volumetrics}, {"dof", nullptr, &s.dof}, + {"bloom", nullptr, &s.bloom}, {"reflections", nullptr, &s.reflections}, + {"fogDensity", &s.fogDensity, nullptr}, {"fogHeightFalloff", &s.fogHeightFalloff, nullptr}, + {"godRays", &s.godRayIntensity, nullptr}, {"shaftStrength", &s.shaftStrength, nullptr}, + {"shaftCoverage", &s.shaftCoverage, nullptr}, {"cookieStrength", &s.cookieStrength, nullptr}, + {"exposure", &s.exposure, nullptr}, {"bloomStrength", &s.bloomStrength, nullptr}, + {"tiltShift", &s.tiltShift, nullptr}, {"focusRange", &s.focusRange, nullptr}, + {"shaftScale", &s.shaftScale, nullptr}, {"occluderFade", nullptr, &s.occluderFade}, + {"ssao", nullptr, &s.ssao}, {"groundAO", nullptr, &s.groundOcclusion}, + {"ssaoRadius", &s.ssaoRadius, nullptr}, {"ssaoIntensity", &s.ssaoIntensity, nullptr}, + }; + for (Entry& e : table) + if (name == e.name) { + if (e.f) *e.f = v; + else *e.b = v != 0.0f; + return true; + } + return false; +} + +std::string timestampedScreenshotPath() { + std::time_t t = std::time(nullptr); + char buf[64]; + std::strftime(buf, sizeof buf, "screenshots/hd2d_%Y%m%d_%H%M%S.png", std::localtime(&t)); + return buf; +} + +// Owns every GL object so they are destroyed while the context still exists. +int run(hd2d::Window& window, const Options& opt) { + const bool capture = !opt.screenshot.empty(); + + hd2d::Renderer renderer; + if (!renderer.init(std::string(HD2D_ASSET_DIR) + "/shaders", window.width(), window.height())) return 1; + for (const auto& [name, value] : opt.settings) + if (!applySetting(renderer.settings, name, value)) HD2D_ERR("unknown setting '%s'", name.c_str()); + + DemoScene demo; + if (!demo.load(HD2D_ASSET_DIR)) { + HD2D_ERR("failed to load demo assets from %s", HD2D_ASSET_DIR); + return 1; + } + if (opt.hasPos) demo.placePlayer(opt.pos); + if (opt.hasSun) { + demo.scene.sun.azimuth = glm::radians(opt.sun.x); + demo.scene.sun.elevation = glm::radians(opt.sun.y); + } + if (opt.rain > 0.0f) { + demo.weather.setRain(opt.rain); + demo.weather.snap(); // captures start already raining + } + + hd2d::DebugUI ui; + if (!ui.init(window)) return 1; + bool showUI = capture ? opt.ui : true; + + hd2d::Camera cam; + if (opt.hasYaw) cam.yaw = glm::radians(opt.yaw); + if (opt.dist > 0.0f) cam.distance = opt.dist; + cam.target = demo.playerPos + glm::vec3(0, 0.7f, 0); + + using clock = std::chrono::steady_clock; + auto last = clock::now(); + float time = 0.0f; + int frame = 0; + + while (window.pollEvents()) { + auto now = clock::now(); + float dt = std::min(std::chrono::duration(now - last).count(), 0.05f); + last = now; + if (capture) dt = 1.0f / 60.0f; // deterministic captures + time += dt; + + if (window.resized()) renderer.resize(window.width(), window.height()); + + glm::vec2 move = opt.move; + if (!ui.wantsKeyboard()) { + if (window.keyDown(GLFW_KEY_W) || window.keyDown(GLFW_KEY_UP)) move.y += 1.0f; + if (window.keyDown(GLFW_KEY_S) || window.keyDown(GLFW_KEY_DOWN)) move.y -= 1.0f; + if (window.keyDown(GLFW_KEY_D) || window.keyDown(GLFW_KEY_RIGHT)) move.x += 1.0f; + if (window.keyDown(GLFW_KEY_A) || window.keyDown(GLFW_KEY_LEFT)) move.x -= 1.0f; + if (window.keyDown(GLFW_KEY_Q)) cam.yaw -= dt * 1.2f; + if (window.keyDown(GLFW_KEY_E)) cam.yaw += dt * 1.2f; + if (window.keyPressed(GLFW_KEY_F1)) showUI = !showUI; + if (window.keyPressed(GLFW_KEY_F5)) renderer.reloadShaders(); + if (window.keyPressed(GLFW_KEY_R)) demo.weather.setRain(demo.weather.targetRain() > 0.0f ? 0.0f : 1.0f); + if (window.keyPressed(GLFW_KEY_ESCAPE)) window.requestClose(); + } + if (!ui.wantsMouse()) { + cam.distance = std::clamp(cam.distance * std::pow(0.9f, window.scrollDelta()), 8.0f, 45.0f); + if (window.mouseDown(GLFW_MOUSE_BUTTON_RIGHT)) { + glm::vec2 d = window.mouseDelta(); + cam.yaw += d.x * 0.005f; + cam.pitch = std::clamp(cam.pitch + d.y * 0.004f, 0.2f, 1.35f); + } + } + + demo.update(dt, time, move, cam); + 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)); + + demo.weather.update(dt, demo.scene, renderer.settings); + renderer.render(demo.scene, cam, time); + + if (showUI) { + ui.beginFrame(); + hd2d::DebugUI::renderSettingsPanel(renderer.settings, demo.scene, renderer.gpuFrameMs()); + hd2d::DebugUI::weatherPanel(demo.weather); + ImGui::SetNextWindowPos(ImVec2(float(window.width()) - 12.0f, 12.0f), ImGuiCond_FirstUseEver, + ImVec2(1.0f, 0.0f)); + ImGui::SetNextWindowBgAlpha(0.5f); + ImGui::Begin("Controls", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar); + ImGui::TextUnformatted("WASD move | Q/E rotate | RMB orbit | wheel zoom"); + ImGui::TextUnformatted("R toggle rain | F1 panel | F5 reload shaders | F12 screenshot"); + ImGui::Text("player %.1f %.1f %.1f", demo.playerPos.x, demo.playerPos.y, demo.playerPos.z); + ImGui::End(); + ui.endFrame(); + } + + ++frame; + if (!capture && window.keyPressed(GLFW_KEY_F12)) + hd2d::saveScreenshot(timestampedScreenshotPath(), window.width(), window.height()); + if (capture && frame >= opt.frames) { + hd2d::saveScreenshot(opt.screenshot, window.width(), window.height()); + HD2D_LOG("player at %.2f %.2f %.2f, GPU %.2f ms", demo.playerPos.x, demo.playerPos.y, demo.playerPos.z, + renderer.gpuFrameMs()); + break; + } + window.swapBuffers(); + } + ui.shutdown(); + return 0; +} + +} // namespace + +int main(int argc, char** argv) { + Options opt = parseArgs(argc, argv); + hd2d::Window window; + // Captures use a fixed-size (floating) window so screenshots are reproducible. + if (!window.create(opt.width, opt.height, "HD-2D Engine Demo", true, opt.screenshot.empty())) return 1; + int rc = run(window, opt); + window.destroy(); + return rc; +} diff --git a/docs/rain.png b/docs/rain.png new file mode 100644 index 0000000..c04407c Binary files /dev/null and b/docs/rain.png differ diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 0000000..33004de Binary files /dev/null and b/docs/screenshot.png differ diff --git a/engine/include/hd2d/Billboard.h b/engine/include/hd2d/Billboard.h new file mode 100644 index 0000000..44d549e --- /dev/null +++ b/engine/include/hd2d/Billboard.h @@ -0,0 +1,49 @@ +#pragma once +#include "hd2d/GL.h" +#include "hd2d/Texture.h" + +#include +#include + +namespace hd2d { + +// One upright pixel-art sprite. `pos` is where the lowest opaque pixel row +// touches the ground; sprites always face the camera around the Y axis. +struct BillboardInstance { + glm::vec3 pos{0.0f}; + float sway = 0.0f; // wind / player-push response (0 = rigid) + glm::vec2 size{1.0f}; // world units of the full texture quad + float flip = 0.0f; // 1 = mirror horizontally + float tint = 1.0f; // brightness multiplier (breaks up repetition) +}; + +// Instanced batch of billboards sharing one texture. +class BillboardBatch { +public: + BillboardBatch() = default; + BillboardBatch(const BillboardBatch&) = delete; + BillboardBatch& operator=(const BillboardBatch&) = delete; + ~BillboardBatch(); + + bool loadTexture(const std::string& path) { return texture.load(path); } + void upload(); // call after editing `instances` + void draw() const; + + // World size that keeps the texture at `pixelsPerUnit` texel density. + glm::vec2 sizeForDensity(float pixelsPerUnit) const { + return glm::vec2(texture.size()) / pixelsPerUnit; + } + + Texture texture; + std::vector instances; + bool castShadows = true; + bool inReflections = true; + float emissive = 0.0f; // unlit contribution, for glowing sprites + +private: + GLuint vao_ = 0, vbo_ = 0; + size_t capacity_ = 0; + GLsizei count_ = 0; +}; + +} // namespace hd2d diff --git a/engine/include/hd2d/Camera.h b/engine/include/hd2d/Camera.h new file mode 100644 index 0000000..e3b7bcd --- /dev/null +++ b/engine/include/hd2d/Camera.h @@ -0,0 +1,24 @@ +#pragma once +#include + +namespace hd2d { + +// Orbit camera looking at `target`. HD-2D scenes use a high, narrow-FOV +// (near telephoto) camera tilted down so the world reads like a diorama. +struct Camera { + glm::vec3 target{0.0f}; + float yaw = 0.0f; // radians; 0 looks toward -Z + float pitch = 0.56f; // radians below horizon + float distance = 17.0f; + float fovY = 0.52f; // radians (~30 deg) + float nearPlane = 0.5f; + float farPlane = 250.0f; + + glm::vec3 forward() const; // unit vector camera -> target + glm::vec3 position() const { return target - forward() * distance; } + glm::vec3 right() const; // horizontal right vector + glm::mat4 view() const; + glm::mat4 projection(float aspect) const; +}; + +} // namespace hd2d diff --git a/engine/include/hd2d/DebugUI.h b/engine/include/hd2d/DebugUI.h new file mode 100644 index 0000000..363a54a --- /dev/null +++ b/engine/include/hd2d/DebugUI.h @@ -0,0 +1,29 @@ +#pragma once + +namespace hd2d { + +class Window; +struct RenderSettings; +struct Scene; +class Weather; + +// Dear ImGui integration plus a ready-made tuning panel for the renderer. +class DebugUI { +public: + bool init(Window& window); + void shutdown(); + void beginFrame(); + void endFrame(); + bool wantsMouse() const; + bool wantsKeyboard() const; + + // Tweak panel for every look parameter (sun, sky, fog, AO, water, post). + static void renderSettingsPanel(RenderSettings& settings, Scene& scene, float gpuMs); + // Rain amount, transition time and the storm preset. + static void weatherPanel(Weather& weather); + +private: + bool initialized_ = false; +}; + +} // namespace hd2d diff --git a/engine/include/hd2d/GL.h b/engine/include/hd2d/GL.h new file mode 100644 index 0000000..e31de98 --- /dev/null +++ b/engine/include/hd2d/GL.h @@ -0,0 +1,4 @@ +#pragma once +// Single include point for OpenGL. libepoxy resolves entry points for both +// GLX (X11) and EGL (Wayland) contexts, so no loader init call is needed. +#include diff --git a/engine/include/hd2d/GroundOcclusion.h b/engine/include/hd2d/GroundOcclusion.h new file mode 100644 index 0000000..4afaeff --- /dev/null +++ b/engine/include/hd2d/GroundOcclusion.h @@ -0,0 +1,35 @@ +#pragma once +#include "hd2d/GL.h" + +#include +#include + +namespace hd2d { + +class ModelBatch; + +// Baked contact ambient occlusion on the ground under 3D scenery: a dark, +// tight core at a tree trunk / bush / rock base plus a wide, soft canopy +// shadow under tall models. Sampled by terrain, sprite and model shaders, so +// grass tufts and bushes growing at a tree's foot darken too. Rebuild after +// moving instances. +class GroundOcclusion { +public: + GroundOcclusion() = default; + GroundOcclusion(const GroundOcclusion&) = delete; + GroundOcclusion& operator=(const GroundOcclusion&) = delete; + ~GroundOcclusion(); + + // Covers the rectangle [min, max] (world xz). Uses every batch whose + // `groundOcclusion` strength is > 0. + void build(glm::vec2 min, glm::vec2 max, const std::vector& batches, int texelsPerUnit = 8); + + GLuint texture() const { return tex_; } + glm::vec4 rect() const { return rect_; } // matches uGroundAO in common.glsl + +private: + GLuint tex_ = 0; + glm::vec4 rect_{0.0f}; +}; + +} // namespace hd2d diff --git a/engine/include/hd2d/Log.h b/engine/include/hd2d/Log.h new file mode 100644 index 0000000..d020135 --- /dev/null +++ b/engine/include/hd2d/Log.h @@ -0,0 +1,5 @@ +#pragma once +#include + +#define HD2D_LOG(fmt, ...) std::fprintf(stdout, "[hd2d] " fmt "\n" __VA_OPT__(, ) __VA_ARGS__) +#define HD2D_ERR(fmt, ...) std::fprintf(stderr, "[hd2d:error] " fmt "\n" __VA_OPT__(, ) __VA_ARGS__) diff --git a/engine/include/hd2d/Model.h b/engine/include/hd2d/Model.h new file mode 100644 index 0000000..4704866 --- /dev/null +++ b/engine/include/hd2d/Model.h @@ -0,0 +1,106 @@ +#pragma once +#include "hd2d/GL.h" +#include "hd2d/Texture.h" + +#include +#include +#include +#include +#include +#include + +namespace hd2d { + +// Material of a glTF primitive. Materials whose name starts with "foliage" +// get alpha testing, two-sided wrap lighting and sun translucency. +struct ModelMaterial { + std::string name; + const Texture* texture = nullptr; // base color (sRGB); null = white + glm::vec4 baseColor{1.0f}; + bool alphaMask = false; + float alphaCutoff = 0.5f; + bool doubleSided = false; + bool foliage = false; + glm::vec3 emissive{0.0f}; // linear HDR (factor x strength) + const Texture* emissiveTexture = nullptr; // multiplies `emissive`; null = white +}; + +struct ModelPrimitive { + GLuint vbo = 0, ibo = 0; + GLsizei indexCount = 0; + const ModelMaterial* material = nullptr; // owned by the ModelLibrary +}; + +// Static mesh made of primitives (one per material). Vertex layout: position, +// normal, uv, color (rgb = tint/AO, a = wind weight). Built from one root +// node of a glTF file with the node's world transform baked in. +struct Model { + std::string name; + std::vector primitives; + glm::vec3 boundsMin{0.0f}, boundsMax{0.0f}; + // Named attachment points from child empties of the glTF node (model space), + // e.g. "fire_0" on the bridge braziers. + std::unordered_map sockets; +}; + +// Loads a .glb/.gltf once (textures shared by all its models) and exposes each +// root node with a mesh as a named Model. +class ModelLibrary { +public: + ModelLibrary() = default; + ModelLibrary(const ModelLibrary&) = delete; + ModelLibrary& operator=(const ModelLibrary&) = delete; + ~ModelLibrary(); + + bool load(const std::string& path); + const Model* get(const std::string& name) const; + +private: + std::vector> textures_; + std::vector> materials_; + std::unordered_map> models_; +}; + +// Per-instance placement: rotation about +Y, non-uniform scale, brightness tint. +struct ModelInstance { + glm::vec3 pos{0.0f}; + float yaw = 0.0f; + glm::vec3 scale{1.0f}; + float tint = 1.0f; + + glm::vec3 toWorld(const glm::vec3& local) const { // matches mesh.vert + glm::vec3 p = local * scale; + float c = std::cos(yaw), s = std::sin(yaw); + return pos + glm::vec3(c * p.x + s * p.z, p.y, -s * p.x + c * p.z); + } +}; + +// Instanced draw of one model. +class ModelBatch { +public: + explicit ModelBatch(const Model* model = nullptr) : model(model) {} + ModelBatch(const ModelBatch&) = delete; + ModelBatch& operator=(const ModelBatch&) = delete; + ~ModelBatch(); + + void upload(); // call after editing `instances` + // Draws primitive `i` (the renderer binds its material first). + void drawPrimitive(size_t i) const; + + const Model* model = nullptr; + std::vector instances; + bool castShadows = true; + bool inReflections = true; + float windScale = 1.0f; // multiplies the per-vertex wind weight + // Strength of the contact shadow baked into the ground under each instance + // (see GroundOcclusion); 0 disables. Its size follows the model's footprint. + float groundOcclusion = 0.0f; + +private: + std::vector vaos_; + GLuint instanceVbo_ = 0; + size_t capacity_ = 0; + GLsizei count_ = 0; +}; + +} // namespace hd2d diff --git a/engine/include/hd2d/Particles.h b/engine/include/hd2d/Particles.h new file mode 100644 index 0000000..d5b3a0a --- /dev/null +++ b/engine/include/hd2d/Particles.h @@ -0,0 +1,85 @@ +#pragma once +#include "hd2d/GL.h" + +#include +#include +#include + +namespace hd2d { + +struct PointLight; + +// High-resolution (non pixel-art) particles: ambient fields (fireflies, dust), +// fire emitters (flames, embers, smoke, glow + flickering light) and bursts. +class ParticleSystem { +public: + // Must match the kind ids in particles.vert / particles.frag. + enum class Kind : int { + Firefly = 0, // blinking emissive glow, wanders near the ground + Mote = 1, // dust speck only visible where sunlight hits it (god rays) + Spark = 2, // ballistic burst particle (footstep dust, rain splashes, ...) + Flame = 3, // additive tongue of fire, eroded by noise as it ages + Ember = 4, // tiny hot spark drifting up on turbulent air + Smoke = 5, // alpha-blended puff rising above a fire + Glow = 6, // large soft halo around a fire (one per emitter per frame) + }; + + struct Field { // region that keeps a constant population of ambient particles + Kind kind; + glm::vec3 min, max; + int count; + }; + + struct Fire { + glm::vec3 pos; // base of the flames + float scale = 1.0f; // overall size / intensity + float flameAccum = 0.0f, emberAccum = 0.0f, smokeAccum = 0.0f; + float flicker = 1.0f; // current light flicker multiplier + float phase = 0.0f; + }; + + ~ParticleSystem(); + void addField(Kind kind, glm::vec3 min, glm::vec3 max, int count); + int addFire(glm::vec3 pos, float scale = 1.0f); + void burst(glm::vec3 pos, int count, glm::vec3 color, float speed, float size, float life); + void update(float dt, float time); + void draw() const; + // Appends fire lights first, then up to `maxFireflies` firefly lights. + void collectLights(std::vector& out, int maxFireflies) const; + const std::vector& fires() const { return fires_; } + + glm::vec3 fireflyColor{2.6f, 3.2f, 0.9f}; + glm::vec3 moteColor{1.0f, 0.95f, 0.8f}; + float fireflyLightRadius = 2.2f; + glm::vec3 fireLightColor{4.0f, 1.75f, 0.55f}; + float fireLightRadius = 7.0f; + glm::vec3 wind{0.0f}; // world-space drift applied to smoke and embers + float ambientFade = 1.0f; // scales fireflies + motes (Weather lowers it in rain) + +private: + struct Particle { + glm::vec3 pos, vel; + glm::vec3 color; + float size, age, life, phase; + Kind kind; + int field; // ambient field index, or -1 + int fire; // owning fire, or -1 + }; + struct GpuParticle { + glm::vec4 posSize; + glm::vec4 color; // rgb, a = alpha + glm::vec4 params; // x = kind, y = age / life, z = random seed + }; + void spawnInField(Particle& p, int fieldIndex, bool randomAge); + void spawnFire(const Fire& f, int fireIndex, Kind kind); + + std::vector fields_; + std::vector fires_; + std::vector particles_; + std::vector gpu_; + std::mt19937 rng_{1337}; + GLuint vao_ = 0, vbo_ = 0; + size_t capacity_ = 0; +}; + +} // namespace hd2d diff --git a/engine/include/hd2d/RenderTarget.h b/engine/include/hd2d/RenderTarget.h new file mode 100644 index 0000000..7892de7 --- /dev/null +++ b/engine/include/hd2d/RenderTarget.h @@ -0,0 +1,40 @@ +#pragma once +#include "hd2d/GL.h" + +#include +#include + +namespace hd2d { + +// Creates an immutable 2D texture with clamp-to-edge sampling. +GLuint createTexture2D(GLenum internalFormat, int w, int h, GLenum filter = GL_LINEAR, int levels = 1); + +// Framebuffer owning its color attachments. Depth can be owned (depthFormat) +// or borrowed from another target (sharedDepth) so passes can depth-test +// against an earlier pass without copying. +class RenderTarget { +public: + RenderTarget() = default; + RenderTarget(const RenderTarget&) = delete; + RenderTarget& operator=(const RenderTarget&) = delete; + ~RenderTarget() { destroy(); } + + void create(int w, int h, std::initializer_list colorFormats, GLenum depthFormat = 0, + GLuint sharedDepth = 0, GLenum filter = GL_LINEAR); + void destroy(); + void bind() const; // binds FBO and sets viewport + + GLuint fbo() const { return fbo_; } + GLuint color(int i = 0) const { return colors_[i]; } + GLuint depth() const { return depth_; } + int width() const { return w_; } + int height() const { return h_; } + +private: + GLuint fbo_ = 0, depth_ = 0; + bool ownsDepth_ = false; + std::vector colors_; + int w_ = 0, h_ = 0; +}; + +} // namespace hd2d diff --git a/engine/include/hd2d/Renderer.h b/engine/include/hd2d/Renderer.h new file mode 100644 index 0000000..c465f21 --- /dev/null +++ b/engine/include/hd2d/Renderer.h @@ -0,0 +1,100 @@ +#pragma once +#include "hd2d/Camera.h" +#include "hd2d/RenderTarget.h" +#include "hd2d/Scene.h" +#include "hd2d/Shader.h" + +#include +#include +#include + +namespace hd2d { + +// All tunable look parameters of the HD-2D pipeline. +struct RenderSettings { + // Shadows + int shadowMapSize = 4096; + float shadowExtent = 34.0f; // half-size of the sun's ortho frustum, world units + float shadowSoftness = 1.5f; // PCF radius in texels + + // Dappled canopy light ("cookie" projected along the sun direction) + float cookieStrength = 0.55f; + float cookieScale = 0.13f; + float cookieSpeed = 0.05f; + float cookieCoverage = 0.48f; // fraction of light blocked + // God ray beams: the same implied canopy, sparser, applied inside the fog + float shaftStrength = 0.9f; // 1 = sun scatters only inside beams + float shaftCoverage = 0.62f; // higher = fewer, narrower beams + float shaftScale = 0.45f; // beam pattern frequency (larger = thinner beams) + + // Volumetric fog + god rays + bool volumetrics = true; + glm::vec3 fogColor{0.30f, 0.36f, 0.45f}; // ambient fog in-scatter tint + float fogDensity = 0.008f; + float fogHeightFalloff = 0.15f; + float fogBaseHeight = 0.0f; + float fogNoise = 0.6f; + float fogAnisotropy = 0.62f; // Henyey-Greenstein g (forward scattering) + float godRayIntensity = 4.5f; + float fogMaxDistance = 90.0f; + int fogSteps = 56; + + // Wind for foliage + glm::vec2 windDirection{0.8f, 0.35f}; + float windStrength = 0.06f; + float windGustSpeed = 1.2f; + + // Depth of field (tilt-shift miniature look) + bool dof = true; + float focusRange = 6.0f; // fully sharp half-width around the focus distance + float focusTransition = 12.0f; // distance over which blur ramps up + float dofMaxRadius = 9.0f; // max blur radius in half-res pixels + float tiltShift = 0.35f; // extra blur toward screen top/bottom + + // Bloom + bool bloom = true; + float bloomStrength = 0.075f; + float bloomRadius = 1.0f; + + // Color grading + float exposure = 1.0f; + float contrast = 1.08f; + float saturation = 1.12f; + glm::vec3 lift{0.012f, 0.010f, 0.025f}; // added to shadows + glm::vec3 gain{1.04f, 1.00f, 0.93f}; // multiplied into highlights + float vignette = 0.42f; + float chromaticAberration = 0.0015f; + float grain = 0.025f; + + bool reflections = true; + bool occluderFade = true; // dither away 3D scenery between the camera and the player + + // Screen-space ambient occlusion (contact darkening everywhere; the baked + // GroundOcclusion handles the large soft shade under trees) + bool ssao = true; + bool groundOcclusion = true; // use Scene::groundOcclusion (baked contact AO) + float ssaoRadius = 0.7f; // world units + float ssaoIntensity = 1.6f; + + int maxRainDrops = 18000; // streaks drawn at Scene::rain = 1 +}; + +class Renderer { +public: + Renderer(); + ~Renderer(); + bool init(const std::string& shaderDir, int width, int height); + void resize(int width, int height); + bool reloadShaders(); + void render(const Scene& scene, const Camera& camera, float time); + + RenderSettings settings; + float gpuFrameMs() const { return gpuMs_; } + +private: + struct Impl; + std::unique_ptr impl_; + float gpuMs_ = 0.0f; +}; + +} // namespace hd2d diff --git a/engine/include/hd2d/Scene.h b/engine/include/hd2d/Scene.h new file mode 100644 index 0000000..e1fc116 --- /dev/null +++ b/engine/include/hd2d/Scene.h @@ -0,0 +1,60 @@ +#pragma once +#include "hd2d/Billboard.h" +#include "hd2d/GroundOcclusion.h" +#include "hd2d/Model.h" +#include "hd2d/Particles.h" +#include "hd2d/Terrain.h" +#include "hd2d/Water.h" + +#include +#include + +namespace hd2d { + +struct PointLight { + glm::vec3 position{0.0f}; + float radius = 3.0f; + glm::vec3 color{1.0f}; // linear HDR +}; + +struct Sun { + float azimuth = -0.6f; // radians, rotation around Y (0 = toward -Z, the default view direction) + float elevation = 0.66f; // radians above horizon + glm::vec3 color{1.0f, 0.86f, 0.66f}; + float intensity = 3.2f; + glm::vec3 direction() const; // unit vector pointing toward the sun +}; + +struct Sky { + glm::vec3 zenith{0.22f, 0.42f, 0.78f}; + glm::vec3 horizon{0.70f, 0.76f, 0.80f}; + glm::vec3 ground{0.20f, 0.24f, 0.14f}; // bounce light from below + float ambient = 0.55f; + float fill = 0.35f; // light from the camera side onto faces turned toward the viewer +}; + +// Everything the renderer draws. Pointers are non-owning; null entries skip. +struct Scene { + Terrain* terrain = nullptr; + Water* water = nullptr; + ParticleSystem* particles = nullptr; + std::vector billboards; // pixel-art sprites (characters, small props) + std::vector models; // 3D scenery (trees, bushes, rocks, bridge) + const GroundOcclusion* groundOcclusion = nullptr; // baked contact AO under scenery + std::vector lights; + int volumetricLights = 0; // the first N lights also scatter in the fog (e.g. fires) + Sun sun; + Sky sky; + glm::vec3 focusPoint{0.0f}; // depth-of-field focus (usually the player) + glm::vec3 interactor{0.0f}; // pushes foliage aside (usually the player) + float interactorRadius = 1.1f; + glm::vec3 blobShadowPos{0.0f}; // soft contact shadow under the player + float blobShadowRadius = 0.0f; // 0 disables + + // Precipitation, usually driven by Weather. + float rain = 0.0f; // 0..1: rain streaks, water ripples, overcast sky + float wetness = 0.0f; // 0..1: darker, glossy surfaces (lags behind `rain`) + float rainSpeed = 15.0f; // drop fall speed (world units / s) +}; + +} // namespace hd2d diff --git a/engine/include/hd2d/Screenshot.h b/engine/include/hd2d/Screenshot.h new file mode 100644 index 0000000..6209dec --- /dev/null +++ b/engine/include/hd2d/Screenshot.h @@ -0,0 +1,9 @@ +#pragma once +#include + +namespace hd2d { + +// Reads the current backbuffer (before swap) and writes it as PNG. +bool saveScreenshot(const std::string& path, int width, int height); + +} // namespace hd2d diff --git a/engine/include/hd2d/Shader.h b/engine/include/hd2d/Shader.h new file mode 100644 index 0000000..b391e63 --- /dev/null +++ b/engine/include/hd2d/Shader.h @@ -0,0 +1,42 @@ +#pragma once +#include "hd2d/GL.h" + +#include +#include +#include + +namespace hd2d { + +// GLSL program loaded from files. Supports `#include "file.glsl"` (resolved +// relative to the including file) and hot reload: a failed reload keeps the +// previous working program. +class Shader { +public: + Shader() = default; + Shader(const Shader&) = delete; + Shader& operator=(const Shader&) = delete; + ~Shader(); + + bool load(const std::string& vertPath, const std::string& fragPath); + bool reload(); + void use() const { glUseProgram(program_); } + GLuint id() const { return program_; } + + void set(const char* name, int v); + void set(const char* name, float v); + void set(const char* name, const glm::vec2& v); + void set(const char* name, const glm::vec3& v); + void set(const char* name, const glm::vec4& v); + void set(const char* name, const glm::ivec2& v); + void set(const char* name, const glm::ivec4& v); + void set(const char* name, const glm::mat4& v); + +private: + GLint location(const char* name); + + GLuint program_ = 0; + std::string vertPath_, fragPath_; + std::unordered_map locations_; +}; + +} // namespace hd2d diff --git a/engine/include/hd2d/Terrain.h b/engine/include/hd2d/Terrain.h new file mode 100644 index 0000000..5ff209a --- /dev/null +++ b/engine/include/hd2d/Terrain.h @@ -0,0 +1,61 @@ +#pragma once +#include "hd2d/GL.h" +#include "hd2d/Texture.h" + +#include +#include +#include +#include + +namespace hd2d { + +// How a material id maps onto layers of the terrain texture array. +struct TerrainMaterial { + int topFirst = 0, topCount = 1; // layers picked per tile for top faces + int accentFirst = 0, accentCount = 0; // occasional variation layers + float accentChance = 0.0f; // probability a tile uses an accent layer + int sideFirst = 0, sideCount = 1; // layers for cliff faces below this tile + int lipLayer = -1; // layer for an overhang strip atop cliff faces (-1 = none) +}; + +// Tile-based diorama terrain: every tile has an integer height level and a +// material. Top faces are drawn with organically jittered material borders +// at pixel granularity; height differences produce textured cliff faces. +class Terrain { +public: + static constexpr int kMaxMaterials = 8; + + void init(int width, int depth, float levelHeight = 0.5f); + int width() const { return w_; } + int depth() const { return d_; } + float levelHeight() const { return levelHeight_; } + + bool inBounds(int x, int z) const { return x >= 0 && z >= 0 && x < w_ && z < d_; } + int level(int x, int z) const; + uint8_t material(int x, int z) const; + void setLevel(int x, int z, int level); + void setMaterial(int x, int z, uint8_t mat); + float heightAt(float x, float z) const; // top surface height of tile under (x, z) + + // Cliff faces at the map border extend down to this level (diorama base). + int baseLevel = -6; + std::array materials{}; + + // (Re)builds GPU mesh + material map after edits. + void build(); + void draw() const; + void bindMaterialMap(GLuint unit) const { glBindTextureUnit(unit, matTex_); } + + TextureArray textures; // pixel-art material layers, all same size + +private: + int w_ = 0, d_ = 0; + float levelHeight_ = 0.5f; + std::vector levels_; + std::vector mats_; + + GLuint vao_ = 0, vbo_ = 0, ibo_ = 0, matTex_ = 0; + GLsizei indexCount_ = 0; +}; + +} // namespace hd2d diff --git a/engine/include/hd2d/Texture.h b/engine/include/hd2d/Texture.h new file mode 100644 index 0000000..e45110e --- /dev/null +++ b/engine/include/hd2d/Texture.h @@ -0,0 +1,68 @@ +#pragma once +#include "hd2d/GL.h" + +#include +#include +#include + +namespace hd2d { + +struct Image { + int width = 0, height = 0; + std::vector rgba; // row 0 = bottom (GL convention) +}; + +// Loads an RGBA8 image flipped to GL orientation. Colors of fully transparent +// pixels are replaced with their nearest opaque neighbour so that filtering +// never bleeds dark fringes into sprite edges. +bool loadImage(const std::string& path, Image& out); +// Decodes PNG bytes from memory. glTF images use flipY = false (their UV +// origin is top-left, which matches GL when rows are uploaded unflipped). +bool decodeImage(const unsigned char* data, size_t size, Image& out, bool flipY); + +// sRGB pixel-art texture. Sampled with "sharp bilinear" in shaders, so it uses +// linear filtering with mipmaps rather than GL_NEAREST. +class Texture { +public: + Texture() = default; + Texture(const Texture&) = delete; + Texture& operator=(const Texture&) = delete; + Texture(Texture&& o) noexcept { *this = std::move(o); } + Texture& operator=(Texture&& o) noexcept; + ~Texture(); + + bool load(const std::string& path, bool repeat = false); + bool create(const Image& image, bool repeat = false, float anisotropy = 1.0f); + void bind(GLuint unit) const { glBindTextureUnit(unit, id_); } + + GLuint id() const { return id_; } + glm::ivec2 size() const { return {width_, height_}; } + // UV-space rectangle (minX, minY, maxX, maxY) of pixels with alpha >= 128. + // Billboards use minY to plant the visible feet on the ground. + glm::vec4 opaqueBounds() const { return opaqueBounds_; } + +private: + GLuint id_ = 0; + int width_ = 0, height_ = 0; + glm::vec4 opaqueBounds_{0, 0, 1, 1}; +}; + +// 2D array of equally sized sRGB layers (terrain materials). +class TextureArray { +public: + TextureArray() = default; + TextureArray(const TextureArray&) = delete; + TextureArray& operator=(const TextureArray&) = delete; + ~TextureArray(); + + bool load(const std::vector& paths); + void bind(GLuint unit) const { glBindTextureUnit(unit, id_); } + glm::ivec2 size() const { return {width_, height_}; } + int layers() const { return layers_; } + +private: + GLuint id_ = 0; + int width_ = 0, height_ = 0, layers_ = 0; +}; + +} // namespace hd2d diff --git a/engine/include/hd2d/Water.h b/engine/include/hd2d/Water.h new file mode 100644 index 0000000..f8e8c7e --- /dev/null +++ b/engine/include/hd2d/Water.h @@ -0,0 +1,50 @@ +#pragma once +#include "hd2d/GL.h" + +#include + +namespace hd2d { + +class Terrain; + +// Planar water body: reflective, refractive, depth-absorbing surface with +// procedural waves, sun glints and shoreline foam. Rendered at full +// resolution (not pixelated) on purpose, like HD-2D's water. +class Water { +public: + ~Water(); + // Builds a tessellated grid covering [minXZ, maxXZ] at height `level`. + void build(glm::vec2 minXZ, glm::vec2 maxXZ, float level, float cellSize = 0.25f); + // Distance-to-shore field (world units) over the water bounds, from every + // terrain tile whose top is above the water. Drives foam and ripples. + void buildShoreField(const Terrain& terrain, int texelsPerUnit = 8); + void draw() const; + float level() const { return level_; } + GLuint shoreTexture() const { return shoreTex_; } + // (minX, minZ, 1/sizeX, 1/sizeZ): maps world xz to shore texture uv. + glm::vec4 shoreRect() const { return {min_.x, min_.y, 1.0f / (max_.x - min_.x), 1.0f / (max_.y - min_.y)}; } + + // Look parameters (tweakable at runtime). + glm::vec3 shallowColor{0.03f, 0.40f, 0.62f}; + glm::vec3 deepColor{0.005f, 0.10f, 0.30f}; + glm::vec3 absorption{1.6f, 0.5f, 0.22f}; // per unit of light path, per channel + float clarity = 0.5f; // how much of the bottom stays visible + float waveScale = 0.9f; + float waveStrength = 1.2f; + float waveSpeed = 0.35f; + float refraction = 0.035f; + float reflectionDistort = 0.05f; + float specular = 6.0f; + float glitter = 1.0f; // twinkling point sparkles + float foamWidth = 0.45f; // world units from the shore + float foamIntensity = 0.85f; + float causticsIntensity = 1.4f; + +private: + GLuint vao_ = 0, vbo_ = 0, ibo_ = 0, shoreTex_ = 0; + GLsizei indexCount_ = 0; + float level_ = 0.0f; + glm::vec2 min_{0.0f}, max_{1.0f}; +}; + +} // namespace hd2d diff --git a/engine/include/hd2d/Weather.h b/engine/include/hd2d/Weather.h new file mode 100644 index 0000000..0204030 --- /dev/null +++ b/engine/include/hd2d/Weather.h @@ -0,0 +1,54 @@ +#pragma once +#include "hd2d/Scene.h" + +#include +#include + +namespace hd2d { + +struct RenderSettings; + +// Everything the weather changes about the look, so it can be blended. +struct WeatherLook { + float sunIntensity = 0.0f; + glm::vec3 sunColor{1.0f}; + glm::vec3 zenith{0.5f}, horizon{0.5f}, ground{0.2f}; + float ambient = 0.5f; + glm::vec3 fogColor{0.5f}; + float fogDensity = 0.0f, fogHeightFalloff = 0.1f, godRays = 0.0f, shaftStrength = 0.0f, cookieStrength = 0.0f; + float windStrength = 0.0f, windGustSpeed = 0.0f; + float bloomStrength = 0.0f, saturation = 1.0f; +}; + +// Blends the scene from its clear-weather look (captured from the current +// scene + settings while it is not raining, so manual tuning is kept) toward +// a storm preset, and drives rain intensity, wetness, ground splashes and the +// fading of ambient particles. +class Weather { +public: + Weather(); + + void setRain(float target) { targetRain_ = glm::clamp(target, 0.0f, 1.0f); } + float targetRain() const { return targetRain_; } + float rain() const { return rain_; } + // Jumps straight to the target (no transition), e.g. for screenshots. + void snap() { rain_ = targetRain_; wetness_ = targetRain_; } + + void update(float dt, Scene& scene, RenderSettings& settings); + + WeatherLook storm; // look at full rain intensity + float transitionSeconds = 5.0f; // clear <-> storm fade time + float splashesPerSecond = 500.0f; + float splashRadius = 12.0f; // around Scene::focusPoint + +private: + static WeatherLook capture(const Scene& scene, const RenderSettings& settings); + static void apply(const WeatherLook& look, Scene& scene, RenderSettings& settings); + + WeatherLook clear_; + bool haveClear_ = false; + float rain_ = 0.0f, targetRain_ = 0.0f, wetness_ = 0.0f, splashAccum_ = 0.0f; + std::mt19937 rng_{99}; +}; + +} // namespace hd2d diff --git a/engine/include/hd2d/Window.h b/engine/include/hd2d/Window.h new file mode 100644 index 0000000..2d7e67b --- /dev/null +++ b/engine/include/hd2d/Window.h @@ -0,0 +1,49 @@ +#pragma once +#include +#include + +struct GLFWwindow; + +namespace hd2d { + +// GLFW window + OpenGL 4.5 core context with simple polled input. +class Window { +public: + // Non-resizable windows float on tiling WMs, keeping an exact size. + bool create(int width, int height, const std::string& title, bool vsync = true, bool resizable = true); + void destroy(); + + // Polls events and refreshes per-frame input state. Returns false once the + // window should close. + bool pollEvents(); + void swapBuffers(); + + bool keyDown(int glfwKey) const; + bool keyPressed(int glfwKey) const; // went down this frame + bool mouseDown(int glfwButton) const; + glm::vec2 mouseDelta() const { return mouseDelta_; } + float scrollDelta() const { return scrollDelta_; } + + int width() const { return width_; } + int height() const { return height_; } + bool resized() const { return resized_; } + GLFWwindow* handle() const { return window_; } + void setTitle(const std::string& title); + void requestClose(); + +private: + static void onFramebufferSize(GLFWwindow* w, int width, int height); + static void onScroll(GLFWwindow* w, double dx, double dy); + + GLFWwindow* window_ = nullptr; + int width_ = 0, height_ = 0; + bool resized_ = false; + bool keys_[512] = {}; + bool prevKeys_[512] = {}; + glm::vec2 lastMouse_{0.0f}; + glm::vec2 mouseDelta_{0.0f}; + float scrollAccum_ = 0.0f; + float scrollDelta_ = 0.0f; +}; + +} // namespace hd2d diff --git a/engine/src/Billboard.cpp b/engine/src/Billboard.cpp new file mode 100644 index 0000000..95d2aad --- /dev/null +++ b/engine/src/Billboard.cpp @@ -0,0 +1,42 @@ +#include "hd2d/Billboard.h" + +#include + +namespace hd2d { + +static_assert(sizeof(BillboardInstance) == 32, "instance layout must match billboard.vert"); + +BillboardBatch::~BillboardBatch() { + if (vbo_) glDeleteBuffers(1, &vbo_); + if (vao_) glDeleteVertexArrays(1, &vao_); +} + +void BillboardBatch::upload() { + if (!vao_) { + glCreateVertexArrays(1, &vao_); + glCreateBuffers(1, &vbo_); + glVertexArrayBindingDivisor(vao_, 0, 1); + glEnableVertexArrayAttrib(vao_, 0); + glVertexArrayAttribFormat(vao_, 0, 4, GL_FLOAT, GL_FALSE, offsetof(BillboardInstance, pos)); + glVertexArrayAttribBinding(vao_, 0, 0); + glEnableVertexArrayAttrib(vao_, 1); + glVertexArrayAttribFormat(vao_, 1, 4, GL_FLOAT, GL_FALSE, offsetof(BillboardInstance, size)); + glVertexArrayAttribBinding(vao_, 1, 0); + } + count_ = GLsizei(instances.size()); + if (instances.size() > capacity_) { + capacity_ = instances.size(); + glNamedBufferData(vbo_, GLsizeiptr(capacity_ * sizeof(BillboardInstance)), nullptr, GL_DYNAMIC_DRAW); + glVertexArrayVertexBuffer(vao_, 0, vbo_, 0, sizeof(BillboardInstance)); + } + if (count_) + glNamedBufferSubData(vbo_, 0, GLsizeiptr(count_ * sizeof(BillboardInstance)), instances.data()); +} + +void BillboardBatch::draw() const { + if (!count_) return; + glBindVertexArray(vao_); + glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, count_); +} + +} // namespace hd2d diff --git a/engine/src/Camera.cpp b/engine/src/Camera.cpp new file mode 100644 index 0000000..59722cb --- /dev/null +++ b/engine/src/Camera.cpp @@ -0,0 +1,19 @@ +#include "hd2d/Camera.h" + +#include +#include + +namespace hd2d { + +glm::vec3 Camera::forward() const { + float cp = std::cos(pitch); + return glm::normalize(glm::vec3(std::sin(yaw) * cp, -std::sin(pitch), -std::cos(yaw) * cp)); +} + +glm::vec3 Camera::right() const { return glm::vec3(std::cos(yaw), 0.0f, std::sin(yaw)); } + +glm::mat4 Camera::view() const { return glm::lookAt(position(), target, glm::vec3(0, 1, 0)); } + +glm::mat4 Camera::projection(float aspect) const { return glm::perspective(fovY, aspect, nearPlane, farPlane); } + +} // namespace hd2d diff --git a/engine/src/DebugUI.cpp b/engine/src/DebugUI.cpp new file mode 100644 index 0000000..edde240 --- /dev/null +++ b/engine/src/DebugUI.cpp @@ -0,0 +1,164 @@ +#include "hd2d/DebugUI.h" + +#include "hd2d/Renderer.h" +#include "hd2d/Weather.h" +#include "hd2d/Window.h" + +#include +#include +#include +#include + +namespace hd2d { + +bool DebugUI::init(Window& window) { + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImGui::GetIO().IniFilename = nullptr; + ImGui::StyleColorsDark(); + ImGui::GetStyle().WindowRounding = 4.0f; + if (!ImGui_ImplGlfw_InitForOpenGL(window.handle(), true)) return false; + if (!ImGui_ImplOpenGL3_Init("#version 450")) return false; + initialized_ = true; + return true; +} + +void DebugUI::shutdown() { + if (!initialized_) return; + ImGui_ImplOpenGL3_Shutdown(); + ImGui_ImplGlfw_Shutdown(); + ImGui::DestroyContext(); + initialized_ = false; +} + +void DebugUI::beginFrame() { + ImGui_ImplOpenGL3_NewFrame(); + ImGui_ImplGlfw_NewFrame(); + ImGui::NewFrame(); +} + +void DebugUI::endFrame() { + ImGui::Render(); + ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); +} + +bool DebugUI::wantsMouse() const { return initialized_ && ImGui::GetIO().WantCaptureMouse; } +bool DebugUI::wantsKeyboard() const { return initialized_ && ImGui::GetIO().WantCaptureKeyboard; } + +void DebugUI::renderSettingsPanel(RenderSettings& s, Scene& scene, float gpuMs) { + ImGui::SetNextWindowPos(ImVec2(12, 12), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(340, 640), ImGuiCond_FirstUseEver); + ImGui::Begin("HD-2D Renderer"); + ImGui::Text("%.1f fps | GPU %.2f ms", ImGui::GetIO().Framerate, gpuMs); + ImGui::Checkbox("Fade scenery hiding the player", &s.occluderFade); + + if (ImGui::CollapsingHeader("Sun & Sky", ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::SliderAngle("Azimuth", &scene.sun.azimuth, -180.0f, 180.0f); + ImGui::SliderAngle("Elevation", &scene.sun.elevation, 3.0f, 89.0f); + ImGui::ColorEdit3("Sun color", glm::value_ptr(scene.sun.color)); + ImGui::SliderFloat("Sun intensity", &scene.sun.intensity, 0.0f, 10.0f); + ImGui::ColorEdit3("Zenith", glm::value_ptr(scene.sky.zenith)); + ImGui::ColorEdit3("Horizon", glm::value_ptr(scene.sky.horizon)); + ImGui::ColorEdit3("Ground bounce", glm::value_ptr(scene.sky.ground)); + ImGui::SliderFloat("Ambient", &scene.sky.ambient, 0.0f, 2.0f); + ImGui::SliderFloat("Camera fill", &scene.sky.fill, 0.0f, 1.5f); + ImGui::SliderFloat("Shadow softness", &s.shadowSoftness, 0.0f, 4.0f); + } + if (ImGui::CollapsingHeader("Canopy light (cookie)")) { + ImGui::SliderFloat("Strength##ck", &s.cookieStrength, 0.0f, 1.0f); + ImGui::SliderFloat("Scale##ck", &s.cookieScale, 0.02f, 0.5f); + ImGui::SliderFloat("Coverage##ck", &s.cookieCoverage, 0.2f, 0.8f); + ImGui::SliderFloat("Drift speed##ck", &s.cookieSpeed, 0.0f, 0.3f); + } + if (ImGui::CollapsingHeader("Ambient occlusion")) { + ImGui::Checkbox("SSAO", &s.ssao); + ImGui::SameLine(); + ImGui::Checkbox("Ground contact AO", &s.groundOcclusion); + ImGui::SliderFloat("Radius##ao", &s.ssaoRadius, 0.1f, 3.0f); + ImGui::SliderFloat("Intensity##ao", &s.ssaoIntensity, 0.0f, 3.0f); + ImGui::TextDisabled("Ground contact AO is baked per ModelBatch"); + } + if (ImGui::CollapsingHeader("Fog & God rays", ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::Checkbox("Volumetrics", &s.volumetrics); + ImGui::ColorEdit3("Fog color", glm::value_ptr(s.fogColor)); + ImGui::SliderFloat("Density", &s.fogDensity, 0.0f, 0.2f, "%.4f"); + ImGui::SliderFloat("Height falloff", &s.fogHeightFalloff, 0.0f, 1.5f); + ImGui::SliderFloat("Base height", &s.fogBaseHeight, -5.0f, 10.0f); + ImGui::SliderFloat("Noise", &s.fogNoise, 0.0f, 1.0f); + ImGui::SliderFloat("Anisotropy", &s.fogAnisotropy, 0.0f, 0.95f); + ImGui::SliderFloat("Shaft strength", &s.shaftStrength, 0.0f, 1.0f); + ImGui::SliderFloat("Shaft coverage", &s.shaftCoverage, 0.2f, 0.8f); + ImGui::SliderFloat("Shaft scale", &s.shaftScale, 0.05f, 1.5f); + ImGui::SliderFloat("God ray intensity", &s.godRayIntensity, 0.0f, 6.0f); + ImGui::SliderInt("Steps", &s.fogSteps, 8, 128); + } + if (scene.water && ImGui::CollapsingHeader("Water")) { + Water& w = *scene.water; + ImGui::Checkbox("Reflections", &s.reflections); + ImGui::ColorEdit3("Shallow", glm::value_ptr(w.shallowColor)); + ImGui::ColorEdit3("Deep", glm::value_ptr(w.deepColor)); + ImGui::SliderFloat3("Absorption", glm::value_ptr(w.absorption), 0.0f, 2.0f); + ImGui::SliderFloat("Clarity", &w.clarity, 0.05f, 2.0f); + ImGui::SliderFloat("Wave scale", &w.waveScale, 0.1f, 4.0f); + ImGui::SliderFloat("Wave strength", &w.waveStrength, 0.0f, 2.0f); + ImGui::SliderFloat("Wave speed", &w.waveSpeed, 0.0f, 2.0f); + ImGui::SliderFloat("Refraction", &w.refraction, 0.0f, 0.1f); + ImGui::SliderFloat("Reflection distort", &w.reflectionDistort, 0.0f, 0.1f); + ImGui::SliderFloat("Glitter", &w.glitter, 0.0f, 4.0f); + ImGui::SliderFloat("Specular", &w.specular, 0.0f, 20.0f); + ImGui::SliderFloat("Foam width", &w.foamWidth, 0.0f, 1.0f); + ImGui::SliderFloat("Foam intensity", &w.foamIntensity, 0.0f, 1.0f); + ImGui::SliderFloat("Caustics", &w.causticsIntensity, 0.0f, 4.0f); + } + if (ImGui::CollapsingHeader("Wind")) { + ImGui::SliderFloat2("Direction", glm::value_ptr(s.windDirection), -1.0f, 1.0f); + ImGui::SliderFloat("Strength##wind", &s.windStrength, 0.0f, 0.3f); + ImGui::SliderFloat("Gust speed", &s.windGustSpeed, 0.0f, 4.0f); + } + if (ImGui::CollapsingHeader("Depth of field", ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::Checkbox("Enabled##dof", &s.dof); + ImGui::SliderFloat("Focus range", &s.focusRange, 0.0f, 20.0f); + ImGui::SliderFloat("Transition", &s.focusTransition, 0.1f, 30.0f); + ImGui::SliderFloat("Max radius", &s.dofMaxRadius, 1.0f, 20.0f); + ImGui::SliderFloat("Tilt-shift", &s.tiltShift, 0.0f, 1.0f); + } + if (ImGui::CollapsingHeader("Bloom & Grade")) { + ImGui::Checkbox("Bloom", &s.bloom); + ImGui::SliderFloat("Bloom strength", &s.bloomStrength, 0.0f, 0.4f); + ImGui::SliderFloat("Bloom radius", &s.bloomRadius, 0.5f, 3.0f); + ImGui::SliderFloat("Exposure", &s.exposure, 0.1f, 4.0f); + ImGui::SliderFloat("Contrast", &s.contrast, 0.5f, 1.6f); + ImGui::SliderFloat("Saturation", &s.saturation, 0.0f, 2.0f); + ImGui::ColorEdit3("Lift", glm::value_ptr(s.lift)); + ImGui::ColorEdit3("Gain", glm::value_ptr(s.gain)); + ImGui::SliderFloat("Vignette", &s.vignette, 0.0f, 1.0f); + ImGui::SliderFloat("Chromatic ab.", &s.chromaticAberration, 0.0f, 0.01f, "%.4f"); + ImGui::SliderFloat("Grain", &s.grain, 0.0f, 0.1f); + } + ImGui::End(); +} + +void DebugUI::weatherPanel(Weather& w) { + ImGui::SetNextWindowPos(ImVec2(364, 12), ImGuiCond_FirstUseEver); + ImGui::Begin("Weather", nullptr, ImGuiWindowFlags_AlwaysAutoResize); + float target = w.targetRain(); + if (ImGui::SliderFloat("Rain", &target, 0.0f, 1.0f)) w.setRain(target); + ImGui::Text("current %.2f (R toggles)", w.rain()); + ImGui::SliderFloat("Transition (s)", &w.transitionSeconds, 0.1f, 20.0f); + if (ImGui::TreeNode("Storm look")) { + WeatherLook& l = w.storm; + ImGui::SliderFloat("Sun intensity##st", &l.sunIntensity, 0.0f, 4.0f); + ImGui::ColorEdit3("Zenith##st", glm::value_ptr(l.zenith)); + ImGui::ColorEdit3("Horizon##st", glm::value_ptr(l.horizon)); + ImGui::SliderFloat("Ambient##st", &l.ambient, 0.0f, 2.0f); + ImGui::ColorEdit3("Fog color##st", glm::value_ptr(l.fogColor)); + ImGui::SliderFloat("Fog density##st", &l.fogDensity, 0.0f, 0.1f, "%.4f"); + ImGui::SliderFloat("Wind##st", &l.windStrength, 0.0f, 0.4f); + ImGui::SliderFloat("Gust speed##st", &l.windGustSpeed, 0.0f, 6.0f); + ImGui::SliderFloat("Splashes / s", &w.splashesPerSecond, 0.0f, 2000.0f); + ImGui::TreePop(); + } + ImGui::End(); +} + +} // namespace hd2d diff --git a/engine/src/GroundOcclusion.cpp b/engine/src/GroundOcclusion.cpp new file mode 100644 index 0000000..ba76bb4 --- /dev/null +++ b/engine/src/GroundOcclusion.cpp @@ -0,0 +1,57 @@ +#include "hd2d/GroundOcclusion.h" + +#include "hd2d/Model.h" + +#include +#include + +namespace hd2d { + +GroundOcclusion::~GroundOcclusion() { + if (tex_) glDeleteTextures(1, &tex_); +} + +void GroundOcclusion::build(glm::vec2 min, glm::vec2 max, const std::vector& batches, + int texelsPerUnit) { + const int w = std::max(1, int(std::ceil((max.x - min.x) * float(texelsPerUnit)))); + const int h = std::max(1, int(std::ceil((max.y - min.y) * float(texelsPerUnit)))); + std::vector occ(size_t(w) * h, 1.0f); + const float tpu = float(texelsPerUnit); + + for (const ModelBatch* b : batches) { + if (!b || !b->model || b->groundOcclusion <= 0.0f) continue; + const glm::vec3 ext = b->model->boundsMax - b->model->boundsMin; + for (const ModelInstance& in : b->instances) { + float footprint = 0.5f * std::max(ext.x * in.scale.x, ext.z * in.scale.z); + float height = b->model->boundsMax.y * in.scale.y; + bool tall = height > 1.6f * footprint; // trees: trunk contact + canopy shade + float rCore = tall ? 0.15f * footprint + 0.45f : 0.8f * footprint; + float rWide = tall ? 0.85f * footprint : 1.3f * footprint; + float kCore = 0.85f * b->groundOcclusion, kWide = (tall ? 0.4f : 0.3f) * b->groundOcclusion; + float reach = std::max(rCore, rWide) * 2.2f; + int x0 = std::max(0, int((in.pos.x - reach - min.x) * tpu)); + int x1 = std::min(w - 1, int((in.pos.x + reach - min.x) * tpu)); + int z0 = std::max(0, int((in.pos.z - reach - min.y) * tpu)); + int z1 = std::min(h - 1, int((in.pos.z + reach - min.y) * tpu)); + for (int z = z0; z <= z1; ++z) + for (int x = x0; x <= x1; ++x) { + glm::vec2 p(min.x + (float(x) + 0.5f) / tpu, min.y + (float(z) + 0.5f) / tpu); + float d = glm::length(p - glm::vec2(in.pos.x, in.pos.z)); + float a = kCore * std::exp(-(d * d) / (rCore * rCore)) + kWide * std::exp(-(d * d) / (rWide * rWide)); + occ[size_t(z) * w + x] *= 1.0f - std::min(a, 0.9f); + } + } + } + + if (tex_) glDeleteTextures(1, &tex_); + glCreateTextures(GL_TEXTURE_2D, 1, &tex_); + glTextureStorage2D(tex_, 1, GL_R16F, w, h); + glTextureSubImage2D(tex_, 0, 0, 0, w, h, GL_RED, GL_FLOAT, occ.data()); + glTextureParameteri(tex_, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTextureParameteri(tex_, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTextureParameteri(tex_, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTextureParameteri(tex_, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + rect_ = glm::vec4(min.x, min.y, 1.0f / (max.x - min.x), 1.0f / (max.y - min.y)); +} + +} // namespace hd2d diff --git a/engine/src/Model.cpp b/engine/src/Model.cpp new file mode 100644 index 0000000..c9e3345 --- /dev/null +++ b/engine/src/Model.cpp @@ -0,0 +1,221 @@ +#include "hd2d/Model.h" + +#include "hd2d/Log.h" + +#include +#include +#include +#include + +#define CGLTF_IMPLEMENTATION +#include + +namespace hd2d { + +namespace { + +struct Vertex { + glm::vec3 pos; + glm::vec3 normal; + glm::vec2 uv; + glm::vec4 color; +}; + +void readAttr(const cgltf_accessor* acc, size_t comps, std::vector& out) { + out.assign(acc->count * comps, 0.0f); + for (size_t i = 0; i < acc->count; ++i) cgltf_accessor_read_float(acc, i, &out[i * comps], comps); +} + +} // namespace + +ModelLibrary::~ModelLibrary() { + for (auto& [name, m] : models_) + for (ModelPrimitive& p : m->primitives) { + glDeleteBuffers(1, &p.vbo); + glDeleteBuffers(1, &p.ibo); + } +} + +bool ModelLibrary::load(const std::string& path) { + cgltf_options options{}; + cgltf_data* data = nullptr; + if (cgltf_parse_file(&options, path.c_str(), &data) != cgltf_result_success || + cgltf_load_buffers(&options, data, path.c_str()) != cgltf_result_success) { + HD2D_ERR("cannot load model file '%s'", path.c_str()); + if (data) cgltf_free(data); + return false; + } + + // Images -> textures (decoded once, shared by every material). + std::vector imageTex(data->images_count, nullptr); + for (size_t i = 0; i < data->images_count; ++i) { + const cgltf_image& im = data->images[i]; + if (!im.buffer_view) continue; // external URIs are not used by our pipeline + const auto* bytes = static_cast(im.buffer_view->buffer->data) + im.buffer_view->offset; + Image img; + if (!decodeImage(bytes, im.buffer_view->size, img, false)) continue; + auto tex = std::make_unique(); + tex->create(img, true, 8.0f); + imageTex[i] = tex.get(); + textures_.push_back(std::move(tex)); + } + + size_t matBase = materials_.size(); // this file's materials start here + for (size_t i = 0; i < data->materials_count; ++i) { + const cgltf_material& m = data->materials[i]; + ModelMaterial mm; + mm.name = m.name ? m.name : ""; + mm.foliage = mm.name.rfind("foliage", 0) == 0; + mm.doubleSided = m.double_sided || mm.foliage; + mm.alphaMask = m.alpha_mode == cgltf_alpha_mode_mask; + mm.alphaCutoff = m.alpha_cutoff > 0.0f ? m.alpha_cutoff : 0.5f; + if (m.has_pbr_metallic_roughness) { + const auto& pbr = m.pbr_metallic_roughness; + mm.baseColor = glm::make_vec4(pbr.base_color_factor); + if (pbr.base_color_texture.texture && pbr.base_color_texture.texture->image) + mm.texture = imageTex[size_t(pbr.base_color_texture.texture->image - data->images)]; + } + float strength = m.has_emissive_strength ? m.emissive_strength.emissive_strength : 1.0f; + mm.emissive = glm::make_vec3(m.emissive_factor) * strength; + if (m.emissive_texture.texture && m.emissive_texture.texture->image) + mm.emissiveTexture = imageTex[size_t(m.emissive_texture.texture->image - data->images)]; + materials_.push_back(std::make_unique(mm)); + } + if (materials_.size() == matBase) materials_.push_back(std::make_unique()); // default + + for (size_t n = 0; n < data->nodes_count; ++n) { + const cgltf_node& node = data->nodes[n]; + if (node.parent || !node.mesh || !node.name) continue; + float world[16]; + cgltf_node_transform_world(&node, world); + glm::mat4 M = glm::make_mat4(world); + glm::mat3 N = glm::transpose(glm::inverse(glm::mat3(M))); + + auto model = std::make_unique(); + model->name = node.name; + model->boundsMin = glm::vec3(1e30f); + model->boundsMax = glm::vec3(-1e30f); + for (size_t c = 0; c < node.children_count; ++c) { + const cgltf_node* child = node.children[c]; + if (child->mesh || !child->name) continue; + float cw[16]; + cgltf_node_transform_world(child, cw); + model->sockets[child->name] = glm::vec3(cw[12], cw[13], cw[14]); + } + for (size_t pi = 0; pi < node.mesh->primitives_count; ++pi) { + const cgltf_primitive& prim = node.mesh->primitives[pi]; + if (prim.type != cgltf_primitive_type_triangles) continue; + std::vector pos, nrm, uv, col; + for (size_t a = 0; a < prim.attributes_count; ++a) { + const cgltf_attribute& at = prim.attributes[a]; + if (at.type == cgltf_attribute_type_position) readAttr(at.data, 3, pos); + else if (at.type == cgltf_attribute_type_normal) readAttr(at.data, 3, nrm); + else if (at.type == cgltf_attribute_type_texcoord && at.index == 0) readAttr(at.data, 2, uv); + else if (at.type == cgltf_attribute_type_color && at.index == 0) + readAttr(at.data, cgltf_num_components(at.data->type), col); + } + size_t count = pos.size() / 3; + if (!count) continue; + size_t colComps = count ? col.size() / count : 0; + std::vector verts(count); + for (size_t v = 0; v < count; ++v) { + Vertex& o = verts[v]; + o.pos = glm::vec3(M * glm::vec4(pos[v * 3], pos[v * 3 + 1], pos[v * 3 + 2], 1.0f)); + o.normal = nrm.empty() ? glm::vec3(0, 1, 0) + : glm::normalize(N * glm::vec3(nrm[v * 3], nrm[v * 3 + 1], nrm[v * 3 + 2])); + o.uv = uv.empty() ? glm::vec2(0.0f) : glm::vec2(uv[v * 2], uv[v * 2 + 1]); + o.color = glm::vec4(1.0f, 1.0f, 1.0f, 0.0f); // no color: untinted and rigid + if (colComps >= 3) o.color = glm::vec4(col[v * colComps], col[v * colComps + 1], col[v * colComps + 2], + colComps == 4 ? col[v * colComps + 3] : 0.0f); + model->boundsMin = glm::min(model->boundsMin, o.pos); + model->boundsMax = glm::max(model->boundsMax, o.pos); + } + std::vector idx; + if (prim.indices) { + idx.resize(prim.indices->count); + for (size_t i = 0; i < idx.size(); ++i) idx[i] = uint32_t(cgltf_accessor_read_index(prim.indices, i)); + } else { + idx.resize(count); + for (size_t i = 0; i < count; ++i) idx[i] = uint32_t(i); + } + + ModelPrimitive mp; + mp.material = materials_[prim.material ? matBase + size_t(prim.material - data->materials) : matBase].get(); + mp.indexCount = GLsizei(idx.size()); + glCreateBuffers(1, &mp.vbo); + glNamedBufferStorage(mp.vbo, GLsizeiptr(verts.size() * sizeof(Vertex)), verts.data(), 0); + glCreateBuffers(1, &mp.ibo); + glNamedBufferStorage(mp.ibo, GLsizeiptr(idx.size() * sizeof(uint32_t)), idx.data(), 0); + model->primitives.push_back(mp); + } + // Opaque first, then alpha-tested: fewer overdraw costs from early depth rejection. + std::stable_sort(model->primitives.begin(), model->primitives.end(), [&](const auto& a, const auto& b) { + return !a.material->alphaMask && b.material->alphaMask; + }); + models_[model->name] = std::move(model); + } + HD2D_LOG("loaded %zu models, %zu textures from %s", models_.size(), textures_.size(), path.c_str()); + cgltf_free(data); + return true; +} + +const Model* ModelLibrary::get(const std::string& name) const { + auto it = models_.find(name); + if (it == models_.end()) { + HD2D_ERR("model '%s' not found", name.c_str()); + return nullptr; + } + return it->second.get(); +} + +static_assert(sizeof(ModelInstance) == 32, "instance layout must match mesh.vert"); + +ModelBatch::~ModelBatch() { + if (!vaos_.empty()) glDeleteVertexArrays(GLsizei(vaos_.size()), vaos_.data()); + if (instanceVbo_) glDeleteBuffers(1, &instanceVbo_); +} + +void ModelBatch::upload() { + if (!model) return; + if (!instanceVbo_) glCreateBuffers(1, &instanceVbo_); + count_ = GLsizei(instances.size()); + if (instances.size() > capacity_) { + capacity_ = instances.size(); + glNamedBufferData(instanceVbo_, GLsizeiptr(capacity_ * sizeof(ModelInstance)), nullptr, GL_DYNAMIC_DRAW); + } + if (count_) + glNamedBufferSubData(instanceVbo_, 0, GLsizeiptr(count_ * sizeof(ModelInstance)), instances.data()); + if (vaos_.empty()) { + vaos_.resize(model->primitives.size()); + glCreateVertexArrays(GLsizei(vaos_.size()), vaos_.data()); + for (size_t i = 0; i < vaos_.size(); ++i) { + GLuint vao = vaos_[i]; + const ModelPrimitive& p = model->primitives[i]; + glVertexArrayVertexBuffer(vao, 0, p.vbo, 0, sizeof(Vertex)); + glVertexArrayElementBuffer(vao, p.ibo); + const GLuint offsets[4] = {offsetof(Vertex, pos), offsetof(Vertex, normal), offsetof(Vertex, uv), + offsetof(Vertex, color)}; + const GLint sizes[4] = {3, 3, 2, 4}; + for (GLuint a = 0; a < 4; ++a) { + glEnableVertexArrayAttrib(vao, a); + glVertexArrayAttribFormat(vao, a, sizes[a], GL_FLOAT, GL_FALSE, offsets[a]); + glVertexArrayAttribBinding(vao, a, 0); + } + glVertexArrayBindingDivisor(vao, 1, 1); + for (GLuint a = 4; a < 6; ++a) { + glEnableVertexArrayAttrib(vao, a); + glVertexArrayAttribFormat(vao, a, 4, GL_FLOAT, GL_FALSE, (a - 4) * 16); + glVertexArrayAttribBinding(vao, a, 1); + } + } + } + for (GLuint vao : vaos_) glVertexArrayVertexBuffer(vao, 1, instanceVbo_, 0, sizeof(ModelInstance)); +} + +void ModelBatch::drawPrimitive(size_t i) const { + if (!count_) return; + glBindVertexArray(vaos_[i]); + glDrawElementsInstanced(GL_TRIANGLES, model->primitives[i].indexCount, GL_UNSIGNED_INT, nullptr, count_); +} + +} // namespace hd2d diff --git a/engine/src/Particles.cpp b/engine/src/Particles.cpp new file mode 100644 index 0000000..20d372d --- /dev/null +++ b/engine/src/Particles.cpp @@ -0,0 +1,262 @@ +#include "hd2d/Particles.h" + +#include "hd2d/Scene.h" + +#include +#include +#include + +namespace hd2d { + +namespace { + +glm::vec3 flameColor(float t) { + // Yellow core -> orange -> deep red, in linear HDR so the core blooms like + // HD-2D's torches. Kept saturated: overlapping tongues add up quickly. + const glm::vec3 c0(4.0f, 2.3f, 0.6f), c1(3.6f, 1.0f, 0.15f), c2(1.8f, 0.26f, 0.04f), c3(0.3f, 0.03f, 0.01f); + if (t < 0.25f) return glm::mix(c0, c1, t / 0.25f); + if (t < 0.6f) return glm::mix(c1, c2, (t - 0.25f) / 0.35f); + return glm::mix(c2, c3, (t - 0.6f) / 0.4f); +} + +} // namespace + +ParticleSystem::~ParticleSystem() { + if (vbo_) glDeleteBuffers(1, &vbo_); + if (vao_) glDeleteVertexArrays(1, &vao_); +} + +void ParticleSystem::addField(Kind kind, glm::vec3 min, glm::vec3 max, int count) { + fields_.push_back({kind, min, max, count}); + int fi = int(fields_.size()) - 1; + for (int i = 0; i < count; ++i) { + Particle p{}; + spawnInField(p, fi, true); + particles_.push_back(p); + } +} + +int ParticleSystem::addFire(glm::vec3 pos, float scale) { + std::uniform_real_distribution u(0.0f, 100.0f); + Fire f; + f.pos = pos; + f.scale = scale; + f.phase = u(rng_); + fires_.push_back(f); + return int(fires_.size()) - 1; +} + +void ParticleSystem::spawnInField(Particle& p, int fi, bool randomAge) { + std::uniform_real_distribution u(0.0f, 1.0f); + const Field& f = fields_[fi]; + p.kind = f.kind; + p.field = fi; + p.fire = -1; + p.pos = f.min + (f.max - f.min) * glm::vec3(u(rng_), u(rng_), u(rng_)); + p.vel = glm::vec3(0.0f); + p.phase = u(rng_) * 100.0f; + if (f.kind == Kind::Firefly) { + p.life = 7.0f + 6.0f * u(rng_); + p.size = 0.07f + 0.04f * u(rng_); + p.color = fireflyColor; + } else { + p.life = 9.0f + 8.0f * u(rng_); + p.size = 0.025f + 0.025f * u(rng_); + p.color = moteColor; + } + p.age = randomAge ? u(rng_) * p.life : 0.0f; +} + +void ParticleSystem::spawnFire(const Fire& f, int fireIndex, Kind kind) { + std::uniform_real_distribution u(0.0f, 1.0f); + std::uniform_real_distribution s(-1.0f, 1.0f); + const float k = f.scale; + Particle p{}; + p.kind = kind; + p.field = -1; + p.fire = fireIndex; + p.phase = u(rng_) * 100.0f; + p.age = 0.0f; + float a = u(rng_) * 6.2832f; + if (kind == Kind::Flame) { + float r = 0.2f * k * std::sqrt(u(rng_)); + p.pos = f.pos + glm::vec3(std::cos(a) * r, u(rng_) * 0.05f * k, std::sin(a) * r); + p.vel = glm::vec3(s(rng_) * 0.15f, 1.3f + 0.8f * u(rng_), s(rng_) * 0.15f) * std::sqrt(k); + p.life = 0.5f + 0.4f * u(rng_); + p.size = 0.42f * k * (0.8f + 0.4f * u(rng_)); + } else if (kind == Kind::Ember) { + float r = 0.15f * k * u(rng_); + p.pos = f.pos + glm::vec3(std::cos(a) * r, 0.1f * k, std::sin(a) * r); + p.vel = glm::vec3(s(rng_) * 0.5f, 1.2f + 1.2f * u(rng_), s(rng_) * 0.5f); + p.life = 1.0f + 1.2f * u(rng_); + p.size = 0.022f + 0.014f * u(rng_); + } else { // Smoke + p.pos = f.pos + glm::vec3(s(rng_) * 0.08f, 0.55f * k, s(rng_) * 0.08f); + p.vel = glm::vec3(s(rng_) * 0.1f, 0.5f + 0.3f * u(rng_), s(rng_) * 0.1f); + p.life = 2.5f + 1.5f * u(rng_); + p.size = 0.25f * k; + } + particles_.push_back(p); +} + +void ParticleSystem::burst(glm::vec3 pos, int count, glm::vec3 color, float speed, float size, float life) { + std::uniform_real_distribution u(-1.0f, 1.0f); + for (int i = 0; i < count; ++i) { + Particle p{}; + p.kind = Kind::Spark; + p.field = -1; + p.fire = -1; + p.pos = pos; + p.vel = glm::vec3(u(rng_), 0.6f + 0.4f * std::abs(u(rng_)), u(rng_)) * speed; + p.color = color; + p.size = size * (0.7f + 0.3f * u(rng_)); + p.life = life * (0.7f + 0.3f * std::abs(u(rng_))); + p.age = 0.0f; + p.phase = 0.0f; + particles_.push_back(p); + } +} + +void ParticleSystem::update(float dt, float time) { + gpu_.clear(); + + // Fire emitters: spawn, flicker, and one big glow sprite each. + for (size_t i = 0; i < fires_.size(); ++i) { + Fire& f = fires_[i]; + float t = time + f.phase; + f.flicker = 0.82f + 0.1f * std::sin(t * 11.0f) + 0.08f * std::sin(t * 23.7f + 1.3f) + 0.06f * std::sin(t * 5.3f); + auto emit = [&](float& acc, float rate, Kind kind) { + acc += rate * dt; + while (acc >= 1.0f) { + acc -= 1.0f; + spawnFire(f, int(i), kind); + } + }; + emit(f.flameAccum, 45.0f * f.scale, Kind::Flame); + emit(f.emberAccum, 14.0f * f.scale, Kind::Ember); + emit(f.smokeAccum, 6.0f * f.scale, Kind::Smoke); + glm::vec3 glow = glm::vec3(1.0f, 0.45f, 0.13f) * 0.9f * f.flicker; + gpu_.push_back({glm::vec4(f.pos + glm::vec3(0, 0.35f * f.scale, 0), 1.1f * f.scale * f.flicker), + glm::vec4(glow, 1.0f), glm::vec4(float(int(Kind::Glow)), 0, 0, 0)}); + } + + for (size_t i = 0; i < particles_.size();) { + Particle& p = particles_[i]; + p.age += dt; + if (p.age >= p.life) { + if (p.field >= 0) { + spawnInField(p, p.field, false); + } else { + particles_[i] = particles_.back(); + particles_.pop_back(); + continue; + } + } + float t = time + p.phase; + float lifeT = p.age / p.life; + float fade = std::min(1.0f, std::min(p.age, p.life - p.age) / 1.5f); + float alpha = fade; + float size = p.size; + glm::vec3 color = p.color; + switch (p.kind) { + case Kind::Firefly: { + // Smooth wandering flight with a slow hovering bob. + glm::vec3 wander(std::sin(t * 0.7f) + std::sin(t * 1.31f + 2.0f) * 0.5f, + std::sin(t * 1.1f + 1.0f) * 0.6f, + std::cos(t * 0.53f) + std::cos(t * 1.7f + 4.0f) * 0.5f); + p.pos += wander * (0.35f * dt); + float blink = 0.5f + 0.5f * std::sin(t * 2.3f); + alpha *= (0.15f + 0.85f * blink * blink * blink) * ambientFade; + break; + } + case Kind::Mote: { + glm::vec3 drift(std::sin(t * 0.21f) * 0.12f, std::sin(t * 0.37f) * 0.05f - 0.02f, + std::cos(t * 0.17f) * 0.12f); + p.pos += drift * dt; + alpha *= ambientFade; + break; + } + case Kind::Spark: + p.vel.y -= 6.0f * dt; + p.vel *= std::exp(-3.0f * dt); + p.pos += p.vel * dt; + alpha = 1.0f - lifeT; + break; + case Kind::Flame: { + const Fire& f = fires_[p.fire]; + p.vel.y += 1.6f * dt; // buoyancy + p.vel.x += (std::sin(t * 9.0f) * 0.9f - (p.pos.x - f.pos.x) * 5.0f) * dt; // flicker + taper + p.vel.z += (std::cos(t * 7.3f) * 0.9f - (p.pos.z - f.pos.z) * 5.0f) * dt; + p.pos += p.vel * dt; + color = flameColor(lifeT) * 0.22f; // tongues overlap a lot; keep the sum orange, not white + alpha = std::min(1.0f, lifeT / 0.08f) * std::pow(1.0f - lifeT, 1.3f); + size = p.size * (0.75f + 0.5f * std::sin(3.1416f * std::min(lifeT * 1.2f, 1.0f))) * (1.0f - 0.5f * lifeT); + break; + } + case Kind::Ember: { + p.vel += (glm::vec3(std::sin(t * 3.0f + p.phase * 7.0f), 0.3f, std::cos(t * 2.6f + p.phase * 5.0f)) * 1.8f + + wind * 0.6f) * dt; + p.vel *= std::exp(-0.8f * dt); + p.pos += p.vel * dt; + float blink = 0.6f + 0.4f * std::sin(t * 17.0f); + color = glm::vec3(6.0f, 2.2f, 0.5f) * blink * (1.0f - 0.6f * lifeT); + alpha = 1.0f - lifeT; + break; + } + case Kind::Smoke: + p.vel += (wind * 0.6f + glm::vec3(0, 0.1f, 0)) * dt; + p.pos += p.vel * dt; + size = p.size * (1.0f + 2.5f * lifeT); + color = glm::vec3(0.10f, 0.095f, 0.09f); + alpha = 0.32f * std::min(1.0f, lifeT / 0.15f) * (1.0f - lifeT); + break; + case Kind::Glow: + break; + } + if (p.field >= 0) { // keep ambient particles inside their field + const Field& f = fields_[p.field]; + p.pos = glm::clamp(p.pos, f.min, f.max); + } + float seed = p.phase - std::floor(p.phase); + gpu_.push_back({glm::vec4(p.pos, size), glm::vec4(color, alpha), glm::vec4(float(int(p.kind)), lifeT, seed, 0)}); + ++i; + } + + if (!vao_) { + glCreateVertexArrays(1, &vao_); + glCreateBuffers(1, &vbo_); + glVertexArrayBindingDivisor(vao_, 0, 1); + for (GLuint a = 0; a < 3; ++a) { + glEnableVertexArrayAttrib(vao_, a); + glVertexArrayAttribFormat(vao_, a, 4, GL_FLOAT, GL_FALSE, GLuint(a * sizeof(glm::vec4))); + glVertexArrayAttribBinding(vao_, a, 0); + } + } + if (gpu_.size() > capacity_) { + capacity_ = gpu_.size() * 2; + glNamedBufferData(vbo_, GLsizeiptr(capacity_ * sizeof(GpuParticle)), nullptr, GL_STREAM_DRAW); + glVertexArrayVertexBuffer(vao_, 0, vbo_, 0, sizeof(GpuParticle)); + } + if (!gpu_.empty()) + glNamedBufferSubData(vbo_, 0, GLsizeiptr(gpu_.size() * sizeof(GpuParticle)), gpu_.data()); +} + +void ParticleSystem::draw() const { + if (gpu_.empty()) return; + glBindVertexArray(vao_); + glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, GLsizei(gpu_.size())); +} + +void ParticleSystem::collectLights(std::vector& out, int maxFireflies) const { + for (const Fire& f : fires_) + out.push_back({f.pos + glm::vec3(0, 0.4f * f.scale, 0), fireLightRadius * f.scale, fireLightColor * f.flicker}); + int n = 0; + for (const GpuParticle& g : gpu_) { + if (n >= maxFireflies) break; + if (int(g.params.x) != int(Kind::Firefly) || g.color.a < 0.02f) continue; + out.push_back({glm::vec3(g.posSize), fireflyLightRadius, glm::vec3(g.color) * g.color.a * 0.35f}); + ++n; + } +} + +} // namespace hd2d diff --git a/engine/src/RenderTarget.cpp b/engine/src/RenderTarget.cpp new file mode 100644 index 0000000..e17fd5d --- /dev/null +++ b/engine/src/RenderTarget.cpp @@ -0,0 +1,61 @@ +#include "hd2d/RenderTarget.h" + +#include "hd2d/Log.h" + +namespace hd2d { + +GLuint createTexture2D(GLenum internalFormat, int w, int h, GLenum filter, int levels) { + GLuint t; + glCreateTextures(GL_TEXTURE_2D, 1, &t); + glTextureStorage2D(t, levels, internalFormat, w, h); + GLenum minFilter = filter; + if (levels > 1) minFilter = filter == GL_LINEAR ? GL_LINEAR_MIPMAP_NEAREST : GL_NEAREST_MIPMAP_NEAREST; + glTextureParameteri(t, GL_TEXTURE_MIN_FILTER, minFilter); + glTextureParameteri(t, GL_TEXTURE_MAG_FILTER, filter); + glTextureParameteri(t, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTextureParameteri(t, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + return t; +} + +void RenderTarget::create(int w, int h, std::initializer_list colorFormats, GLenum depthFormat, + GLuint sharedDepth, GLenum filter) { + destroy(); + w_ = w; + h_ = h; + glCreateFramebuffers(1, &fbo_); + std::vector drawBuffers; + for (GLenum fmt : colorFormats) { + GLuint t = createTexture2D(fmt, w, h, filter); + glNamedFramebufferTexture(fbo_, GLenum(GL_COLOR_ATTACHMENT0 + colors_.size()), t, 0); + drawBuffers.push_back(GLenum(GL_COLOR_ATTACHMENT0 + colors_.size())); + colors_.push_back(t); + } + if (depthFormat) { + depth_ = createTexture2D(depthFormat, w, h, GL_NEAREST); + ownsDepth_ = true; + } else if (sharedDepth) { + depth_ = sharedDepth; + ownsDepth_ = false; + } + if (depth_) glNamedFramebufferTexture(fbo_, GL_DEPTH_ATTACHMENT, depth_, 0); + if (drawBuffers.empty()) glNamedFramebufferDrawBuffer(fbo_, GL_NONE); + else glNamedFramebufferDrawBuffers(fbo_, GLsizei(drawBuffers.size()), drawBuffers.data()); + GLenum status = glCheckNamedFramebufferStatus(fbo_, GL_FRAMEBUFFER); + if (status != GL_FRAMEBUFFER_COMPLETE) HD2D_ERR("framebuffer incomplete: 0x%x", status); +} + +void RenderTarget::destroy() { + if (!colors_.empty()) glDeleteTextures(GLsizei(colors_.size()), colors_.data()); + colors_.clear(); + if (depth_ && ownsDepth_) glDeleteTextures(1, &depth_); + depth_ = 0; + if (fbo_) glDeleteFramebuffers(1, &fbo_); + fbo_ = 0; +} + +void RenderTarget::bind() const { + glBindFramebuffer(GL_FRAMEBUFFER, fbo_); + glViewport(0, 0, w_, h_); +} + +} // namespace hd2d diff --git a/engine/src/Renderer.cpp b/engine/src/Renderer.cpp new file mode 100644 index 0000000..27d9187 --- /dev/null +++ b/engine/src/Renderer.cpp @@ -0,0 +1,584 @@ +#include "hd2d/Renderer.h" + +#include "hd2d/Log.h" + +#include +#include +#include +#include + +namespace hd2d { + +glm::vec3 Sun::direction() const { + float ce = std::cos(elevation); + return glm::normalize(glm::vec3(ce * std::sin(azimuth), std::sin(elevation), -ce * std::cos(azimuth))); +} + +namespace { + +constexpr int kMaxLights = 16; +constexpr int kBloomMips = 6; + +// Mirrors `Frame` in assets/shaders/common.glsl (std140: only mat4/vec4/ivec4). +struct FrameUBO { + glm::mat4 view, proj, viewProj, invViewProj, lightViewProj; + glm::vec4 camPos, camParams, screen, sunDir, sunColor, skyZenith, skyHorizon, skyGround, fogColor, + fogParams, fogParams2, wind, interactor, blob, clipPlane, billboardRight, shadowRight, cookie, + shadowParams, shaft, waterParams, groundAO, weather; + glm::ivec4 lightCount; + glm::vec4 lightPos[kMaxLights]; + glm::vec4 lightColor[kMaxLights]; +}; +static_assert(sizeof(FrameUBO) == 5 * 64 + 23 * 16 + 16 + 2 * kMaxLights * 16, "FrameUBO must be tightly packed"); + +} // namespace + +struct Renderer::Impl { + std::string dir; + int w = 0, h = 0; + const RenderSettings* settings = nullptr; + + Shader terrain, terrainDepth, billboard, mesh, meshDepth, water, sky, volumetric, fogComposite, particles, + dofPrep, dofBlur, dofComposite, bloomDown, bloomUp, tonemap, ssao, ssaoBlur, ssaoApply, rainStreaks; + + RenderTarget shadow, reflection, hdr, post, sceneCopy, depthCopy, volume, dofHalf, dofBlurred, dofOut, ssaoRaw, + ssaoBlurred; + RenderTarget bloom[kBloomMips]; + GLuint postDepthFbo = 0; // post color + hdr depth, for depth-tested particles + int shadowSize = 0; + + GLuint ubo = 0, emptyVao = 0, whiteTex = 0; + GLuint timers[2] = {}; + bool timerUsed[2] = {}; + int timerIdx = 0; + FrameUBO frame{}; + + ~Impl() { + if (ubo) glDeleteBuffers(1, &ubo); + if (emptyVao) glDeleteVertexArrays(1, &emptyVao); + if (whiteTex) glDeleteTextures(1, &whiteTex); + if (timers[0]) glDeleteQueries(2, timers); + if (postDepthFbo) glDeleteFramebuffers(1, &postDepthFbo); + } + + std::string path(const char* name) const { return dir + "/" + name; } + + bool loadShaders() { + bool ok = true; + auto ld = [&](Shader& s, const char* vs, const char* fs) { ok &= s.load(path(vs), path(fs)); }; + ld(terrain, "terrain.vert", "terrain.frag"); + ld(terrainDepth, "terrain.vert", "depth.frag"); + ld(billboard, "billboard.vert", "billboard.frag"); + ld(mesh, "mesh.vert", "mesh.frag"); + ld(meshDepth, "mesh.vert", "mesh_depth.frag"); + ld(water, "water.vert", "water.frag"); + ld(sky, "fullscreen.vert", "sky.frag"); + ld(volumetric, "fullscreen.vert", "volumetric.frag"); + ld(fogComposite, "fullscreen.vert", "fog_composite.frag"); + ld(particles, "particles.vert", "particles.frag"); + ld(dofPrep, "fullscreen.vert", "dof_prep.frag"); + ld(dofBlur, "fullscreen.vert", "dof_blur.frag"); + ld(dofComposite, "fullscreen.vert", "dof_composite.frag"); + ld(bloomDown, "fullscreen.vert", "bloom_down.frag"); + ld(bloomUp, "fullscreen.vert", "bloom_up.frag"); + ld(tonemap, "fullscreen.vert", "tonemap.frag"); + ld(ssao, "fullscreen.vert", "ssao.frag"); + ld(ssaoBlur, "fullscreen.vert", "ssao_blur.frag"); + ld(ssaoApply, "fullscreen.vert", "ssao_apply.frag"); + ld(rainStreaks, "rain.vert", "rain.frag"); + return ok; + } + + bool reloadAll() { + Shader* all[] = {&terrain, &terrainDepth, &billboard, &mesh, &meshDepth, &water, + &sky, &volumetric, &fogComposite, &particles, &dofPrep, &dofBlur, + &dofComposite, &bloomDown, &bloomUp, &tonemap, &ssao, &ssaoBlur, + &ssaoApply, &rainStreaks}; + bool ok = true; + for (Shader* s : all) ok &= s->reload(); + return ok; + } + + void createShadow(int size) { + shadow.create(size, size, {}, GL_DEPTH_COMPONENT32F); + GLuint d = shadow.depth(); + glTextureParameteri(d, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_REF_TO_TEXTURE); + glTextureParameteri(d, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL); + glTextureParameteri(d, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTextureParameteri(d, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + shadowSize = size; + } + + void createTargets(int width, int height) { + w = std::max(1, width); + h = std::max(1, height); + int hw = std::max(1, w / 2), hh = std::max(1, h / 2); + hdr.create(w, h, {GL_RGBA16F}, GL_DEPTH_COMPONENT32F); + post.create(w, h, {GL_RGBA16F}); + sceneCopy.create(w, h, {GL_RGBA16F}); + depthCopy.create(w, h, {}, GL_DEPTH_COMPONENT32F); + reflection.create(hw, hh, {GL_RGBA16F}, GL_DEPTH_COMPONENT32F); + volume.create(hw, hh, {GL_RGBA16F}); + dofHalf.create(hw, hh, {GL_RGBA16F}); + dofBlurred.create(hw, hh, {GL_RGBA16F}); + ssaoRaw.create(hw, hh, {GL_R8}); + ssaoBlurred.create(hw, hh, {GL_R8}); + dofOut.create(w, h, {GL_RGBA16F}); + for (int i = 0; i < kBloomMips; ++i) + bloom[i].create(std::max(1, w >> (i + 1)), std::max(1, h >> (i + 1)), {GL_R11F_G11F_B10F}); + + if (postDepthFbo) glDeleteFramebuffers(1, &postDepthFbo); + glCreateFramebuffers(1, &postDepthFbo); + glNamedFramebufferTexture(postDepthFbo, GL_COLOR_ATTACHMENT0, post.color(), 0); + glNamedFramebufferTexture(postDepthFbo, GL_DEPTH_ATTACHMENT, hdr.depth(), 0); + } + + void setCamera(const glm::mat4& view, const glm::mat4& proj, const glm::vec3& camPos, int tw, int th) { + frame.view = view; + frame.proj = proj; + frame.viewProj = proj * view; + frame.invViewProj = glm::inverse(frame.viewProj); + frame.camPos = glm::vec4(camPos, frame.camPos.w); + frame.screen = glm::vec4(float(tw), float(th), 1.0f / float(tw), 1.0f / float(th)); + } + void upload() { glNamedBufferSubData(ubo, 0, sizeof(FrameUBO), &frame); } + void fullscreen() { + glBindVertexArray(emptyVao); + glDrawArrays(GL_TRIANGLES, 0, 3); + } + + void drawTerrain(const Scene& scene, Shader& s) { + if (!scene.terrain) return; + const Terrain& t = *scene.terrain; + s.use(); + s.set("uMapSize", glm::ivec2(t.width(), t.depth())); + char name[32]; + for (int m = 0; m < Terrain::kMaxMaterials; ++m) { + const TerrainMaterial& mat = t.materials[m]; + std::snprintf(name, sizeof name, "uMatTop[%d]", m); + s.set(name, glm::ivec4(mat.topFirst, mat.topCount, mat.accentFirst, mat.accentCount)); + std::snprintf(name, sizeof name, "uMatAccent[%d]", m); + s.set(name, mat.accentChance); + } + t.textures.bind(0); + t.bindMaterialMap(1); + glEnable(GL_CULL_FACE); + t.draw(); + glDisable(GL_CULL_FACE); + } + + void drawBillboards(const Scene& scene, int pass, bool reflectionPass) { + billboard.use(); + billboard.set("uPass", pass); + for (const BillboardBatch* b : scene.billboards) { + if (!b) continue; + if (pass == 1 && !b->castShadows) continue; + if (reflectionPass && !b->inReflections) continue; + billboard.set("uOpaque", b->texture.opaqueBounds()); + billboard.set("uEmissive", b->emissive); + b->texture.bind(0); + b->draw(); + } + } + + // pass 0 = color, 1 = shadow depth. + void drawModels(const Scene& scene, int pass, bool reflectionPass) { + Shader& s = pass == 1 ? meshDepth : mesh; + s.use(); + if (pass == 0) s.set("uOccluderFade", reflectionPass || !settings->occluderFade ? 0 : 1); + for (const ModelBatch* b : scene.models) { + if (!b || !b->model) continue; + if (pass == 1 && !b->castShadows) continue; + if (reflectionPass && !b->inReflections) continue; + s.set("uWindScale", b->windScale); + for (size_t i = 0; i < b->model->primitives.size(); ++i) { + const ModelMaterial& m = *b->model->primitives[i].material; + s.set("uAlphaMask", m.alphaMask ? 1 : 0); + s.set("uCutoff", m.alphaCutoff); + if (pass == 0) { + s.set("uBaseColor", m.baseColor); + s.set("uFoliage", m.foliage ? 1 : 0); + s.set("uEmissive", m.emissive); + glBindTextureUnit(1, m.emissiveTexture ? m.emissiveTexture->id() : whiteTex); + } + if (m.texture) m.texture->bind(0); + else glBindTextureUnit(0, whiteTex); + if (m.doubleSided || pass == 1) glDisable(GL_CULL_FACE); // shadows: both faces close leaks + else glEnable(GL_CULL_FACE); + b->drawPrimitive(i); + } + } + glDisable(GL_CULL_FACE); + } +}; + +Renderer::Renderer() : impl_(std::make_unique()) {} +Renderer::~Renderer() = default; + +bool Renderer::init(const std::string& shaderDir, int width, int height) { + Impl& I = *impl_; + I.dir = shaderDir; + I.settings = &settings; + if (!I.loadShaders()) return false; + glCreateBuffers(1, &I.ubo); + glNamedBufferData(I.ubo, sizeof(FrameUBO), nullptr, GL_DYNAMIC_DRAW); + glBindBufferBase(GL_UNIFORM_BUFFER, 0, I.ubo); + glCreateVertexArrays(1, &I.emptyVao); + { + const unsigned char white[4] = {255, 255, 255, 255}; + glCreateTextures(GL_TEXTURE_2D, 1, &I.whiteTex); + glTextureStorage2D(I.whiteTex, 1, GL_RGBA8, 1, 1); + glTextureSubImage2D(I.whiteTex, 0, 0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, white); + } + glCreateQueries(GL_TIME_ELAPSED, 2, I.timers); + I.createTargets(width, height); + I.createShadow(settings.shadowMapSize); + return true; +} + +void Renderer::resize(int width, int height) { + if (width > 0 && height > 0) impl_->createTargets(width, height); +} + +bool Renderer::reloadShaders() { + bool ok = impl_->reloadAll(); + HD2D_LOG("shader reload %s", ok ? "ok" : "had errors (kept previous programs)"); + return ok; +} + +void Renderer::render(const Scene& scene, const Camera& cam, float time) { + Impl& I = *impl_; + const RenderSettings& S = settings; + FrameUBO& f = I.frame; + const int w = I.w, h = I.h, hw = I.volume.width(), hh = I.volume.height(); + + // GPU timing (read the query from two frames ago to avoid stalls). + int qi = I.timerIdx; + if (I.timerUsed[qi]) { + GLuint64 ns = 0; + glGetQueryObjectui64v(I.timers[qi], GL_QUERY_RESULT, &ns); + gpuMs_ = float(double(ns) * 1e-6); + } + glBeginQuery(GL_TIME_ELAPSED, I.timers[qi]); + + if (S.shadowMapSize != I.shadowSize) I.createShadow(S.shadowMapSize); + + const float aspect = float(w) / float(h); + const glm::mat4 view = cam.view(), proj = cam.projection(aspect); + const glm::vec3 camPos = cam.position(); + const glm::vec3 sunDir = scene.sun.direction(); + + // Sun shadow frustum follows the camera target, snapped to texels so + // shadows don't shimmer while moving. + const float E = S.shadowExtent; + glm::vec3 up = std::abs(sunDir.y) > 0.99f ? glm::vec3(0, 0, 1) : glm::vec3(0, 1, 0); + glm::mat4 lightView = glm::lookAt(cam.target + sunDir * 80.0f, cam.target, up); + glm::mat4 lightProj = glm::ortho(-E, E, -E, E, 1.0f, 200.0f); + { + glm::vec4 o = lightProj * lightView * glm::vec4(0, 0, 0, 1); + float half = float(I.shadowSize) * 0.5f; + glm::vec2 p = glm::vec2(o) * half; + glm::vec2 off = (glm::round(p) - p) / half; + lightProj[3][0] += off.x; + lightProj[3][1] += off.y; + } + const glm::mat4 lightVP = lightProj * lightView; + + glm::vec3 shadowRight(-sunDir.z, 0.0f, sunDir.x); + if (glm::length(shadowRight) < 1e-3f) shadowRight = cam.right(); + shadowRight = glm::normalize(shadowRight); + if (glm::dot(shadowRight, cam.right()) < 0.0f) shadowRight = -shadowRight; + + f.lightViewProj = lightVP; + f.camPos.w = time; + f.camParams = glm::vec4(cam.nearPlane, cam.farPlane, glm::length(scene.focusPoint - camPos), 0.0f); + f.sunDir = glm::vec4(sunDir, scene.sun.intensity); + f.sunColor = glm::vec4(scene.sun.color, 0.0f); + f.skyZenith = glm::vec4(scene.sky.zenith, scene.sky.ambient); + f.skyHorizon = glm::vec4(scene.sky.horizon, scene.sky.fill); + f.skyGround = glm::vec4(scene.sky.ground, 0.0f); + f.fogColor = glm::vec4(S.fogColor, S.volumetrics ? S.fogDensity : 0.0f); + f.fogParams = glm::vec4(S.fogHeightFalloff, S.fogBaseHeight, S.fogNoise, S.fogAnisotropy); + f.fogParams2 = glm::vec4(S.godRayIntensity, S.fogMaxDistance, float(std::max(4, S.fogSteps)), 0.0f); + glm::vec2 wd = glm::length(S.windDirection) > 1e-4f ? glm::normalize(S.windDirection) : glm::vec2(1, 0); + f.wind = glm::vec4(wd, S.windStrength, S.windGustSpeed); + f.interactor = glm::vec4(scene.interactor, scene.interactorRadius); + f.blob = glm::vec4(scene.blobShadowPos, scene.blobShadowRadius); + f.clipPlane = glm::vec4(0, 0, 0, 1); + f.billboardRight = glm::vec4(cam.right(), 0.0f); + f.shadowRight = glm::vec4(shadowRight, 0.0f); + f.cookie = glm::vec4(S.cookieStrength, S.cookieScale, S.cookieSpeed, S.cookieCoverage); + f.shadowParams = glm::vec4(1.0f / float(I.shadowSize), S.shadowSoftness, 0, 0); + f.shaft = glm::vec4(S.shaftStrength, S.shaftCoverage, S.shaftScale, 0); + f.waterParams = scene.water ? glm::vec4(scene.water->level(), scene.water->causticsIntensity, 1, 0) + : glm::vec4(-1e9f, 0, 0, 0); + f.groundAO = scene.groundOcclusion && S.groundOcclusion ? scene.groundOcclusion->rect() : glm::vec4(0.0f); + f.weather = glm::vec4(scene.rain, scene.wetness, scene.rainSpeed, 0.0f); + glBindTextureUnit(9, scene.groundOcclusion ? scene.groundOcclusion->texture() : I.whiteTex); + int nl = std::min(int(scene.lights.size()), kMaxLights); + f.lightCount = glm::ivec4(nl, std::clamp(scene.volumetricLights, 0, nl), 0, 0); + for (int i = 0; i < nl; ++i) { + f.lightPos[i] = glm::vec4(scene.lights[i].position, scene.lights[i].radius); + f.lightColor[i] = glm::vec4(scene.lights[i].color, 0.0f); + } + + glDisable(GL_BLEND); + glDisable(GL_CULL_FACE); + glEnable(GL_DEPTH_TEST); + glDepthFunc(GL_LEQUAL); + glDepthMask(GL_TRUE); + + // ---- 1. Sun shadow map -------------------------------------------------- + glBindTextureUnit(8, 0); + I.setCamera(lightView, lightProj, cam.target + sunDir * 80.0f, I.shadowSize, I.shadowSize); + I.upload(); + I.shadow.bind(); + glClear(GL_DEPTH_BUFFER_BIT); + glEnable(GL_POLYGON_OFFSET_FILL); + glPolygonOffset(1.5f, 3.0f); + if (scene.terrain) { + I.terrainDepth.use(); + scene.terrain->draw(); // both faces: closes light leaks at cliff edges + } + I.drawBillboards(scene, 1, false); + I.drawModels(scene, 1, false); + glDisable(GL_POLYGON_OFFSET_FILL); + glBindTextureUnit(8, I.shadow.depth()); + + // ---- 2. Planar reflection (half res, mirrored about the water plane) --- + const bool doReflection = scene.water && S.reflections; + if (doReflection) { + const float L = scene.water->level(); + glm::mat4 R = glm::translate(glm::mat4(1.0f), glm::vec3(0, 2.0f * L, 0)) * + glm::scale(glm::mat4(1.0f), glm::vec3(1, -1, 1)); + I.setCamera(view * R, proj, glm::vec3(camPos.x, 2.0f * L - camPos.y, camPos.z), hw, hh); + f.clipPlane = glm::vec4(0, 1, 0, -(L - 0.05f)); + I.upload(); + I.reflection.bind(); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + glEnable(GL_CLIP_DISTANCE0); + glFrontFace(GL_CW); // mirroring flips winding + glDisable(GL_DEPTH_TEST); + I.sky.use(); + I.fullscreen(); + glEnable(GL_DEPTH_TEST); + I.drawTerrain(scene, I.terrain); + I.drawBillboards(scene, 0, true); + I.drawModels(scene, 0, true); + glFrontFace(GL_CCW); + glDisable(GL_CLIP_DISTANCE0); + f.clipPlane = glm::vec4(0, 0, 0, 1); + } + + // ---- 3. Main HDR scene ---------------------------------------------- + I.setCamera(view, proj, camPos, w, h); + I.upload(); + I.hdr.bind(); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + glDisable(GL_DEPTH_TEST); + I.sky.use(); + I.fullscreen(); + glEnable(GL_DEPTH_TEST); + I.drawTerrain(scene, I.terrain); + I.drawModels(scene, 0, false); // opaque models first: early-z for the sprites behind them + I.drawBillboards(scene, 0, false); + + // ---- 3b. SSAO (half res) multiplied into the opaque scene --------------- + // Runs before water so the shore and refracted bottom get AO but the water + // surface does not. + if (S.ssao) { + glDisable(GL_DEPTH_TEST); + glm::vec2 halfTexel(1.0f / float(hw), 1.0f / float(hh)); + I.ssaoRaw.bind(); + I.ssao.use(); + I.ssao.set("uRadius", S.ssaoRadius); + I.ssao.set("uIntensity", S.ssaoIntensity); + glBindTextureUnit(0, I.hdr.depth()); + I.fullscreen(); + I.ssaoBlur.use(); + for (int pass = 0; pass < 2; ++pass) { // separable depth-aware blur + (pass == 0 ? I.ssaoBlurred : I.ssaoRaw).bind(); + I.ssaoBlur.set("uDir", pass == 0 ? glm::vec2(halfTexel.x, 0) : glm::vec2(0, halfTexel.y)); + glBindTextureUnit(0, pass == 0 ? I.ssaoRaw.color() : I.ssaoBlurred.color()); + glBindTextureUnit(1, I.hdr.depth()); + I.fullscreen(); + } + // color *= ao, via blending (no extra copy of the HDR target). + glBindFramebuffer(GL_FRAMEBUFFER, I.hdr.fbo()); + glViewport(0, 0, w, h); + glDepthMask(GL_FALSE); + glEnable(GL_BLEND); + glBlendFunc(GL_DST_COLOR, GL_ZERO); + I.ssaoApply.use(); + I.ssaoApply.set("uAoTexel", halfTexel); + glBindTextureUnit(0, I.ssaoRaw.color()); + glBindTextureUnit(1, I.hdr.depth()); + I.fullscreen(); + glDisable(GL_BLEND); + glDepthMask(GL_TRUE); + glEnable(GL_DEPTH_TEST); + } + + // ---- 4. Water (reads copies of opaque color + depth) ------------------- + if (scene.water) { + glBlitNamedFramebuffer(I.hdr.fbo(), I.sceneCopy.fbo(), 0, 0, w, h, 0, 0, w, h, GL_COLOR_BUFFER_BIT, GL_NEAREST); + glBlitNamedFramebuffer(I.hdr.fbo(), I.depthCopy.fbo(), 0, 0, w, h, 0, 0, w, h, GL_DEPTH_BUFFER_BIT, GL_NEAREST); + const Water& wt = *scene.water; + Shader& s = I.water; + s.use(); + s.set("uShallow", wt.shallowColor); + s.set("uDeep", wt.deepColor); + s.set("uAbsorb", wt.absorption); + s.set("uClarity", wt.clarity); + s.set("uWaveScale", wt.waveScale); + s.set("uWaveStrength", wt.waveStrength); + s.set("uWaveSpeed", wt.waveSpeed); + s.set("uRefraction", wt.refraction); + s.set("uReflDistort", wt.reflectionDistort); + s.set("uSpecular", wt.specular); + s.set("uFoamWidth", wt.foamWidth); + s.set("uFoamIntensity", wt.foamIntensity); + s.set("uGlitter", wt.glitter); + s.set("uShoreRect", wt.shoreRect()); + s.set("uHasReflection", doReflection ? 1 : 0); + glBindTextureUnit(0, I.reflection.color()); + glBindTextureUnit(1, I.sceneCopy.color()); + glBindTextureUnit(2, I.depthCopy.depth()); + glBindTextureUnit(3, wt.shoreTexture()); + I.hdr.bind(); + wt.draw(); + } + glDisable(GL_DEPTH_TEST); + + // ---- 5. Volumetric fog / god rays (half res) --------------------------- + if (S.volumetrics) { + I.volume.bind(); + I.volumetric.use(); + glBindTextureUnit(0, I.hdr.depth()); + I.fullscreen(); + } + I.post.bind(); + I.fogComposite.use(); + I.fogComposite.set("uVolumeTexel", glm::vec2(1.0f / float(hw), 1.0f / float(hh))); + I.fogComposite.set("uEnabled", S.volumetrics ? 1 : 0); + glBindTextureUnit(0, I.hdr.color()); + glBindTextureUnit(1, I.hdr.depth()); + glBindTextureUnit(2, I.volume.color()); + I.fullscreen(); + + // ---- 5b. Rain streaks (procedural, depth tested) ------------------------- + if (scene.rain > 0.001f && S.maxRainDrops > 0) { + glBindFramebuffer(GL_FRAMEBUFFER, I.postDepthFbo); + glViewport(0, 0, w, h); + glEnable(GL_DEPTH_TEST); + glDepthMask(GL_FALSE); + glEnable(GL_BLEND); + glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA); + I.rainStreaks.use(); + I.rainStreaks.set("uRainCenter", cam.target); + I.rainStreaks.set("uRainBox", glm::vec2(16.0f, 14.0f)); + glBindVertexArray(I.emptyVao); + glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, GLsizei(float(S.maxRainDrops) * scene.rain)); + glDisable(GL_BLEND); + glDepthMask(GL_TRUE); + glDisable(GL_DEPTH_TEST); + } + + // ---- 6. Particles (depth tested, no depth writes) ---------------------- + if (scene.particles) { + glBindFramebuffer(GL_FRAMEBUFFER, I.postDepthFbo); + glViewport(0, 0, w, h); + glEnable(GL_DEPTH_TEST); + glDepthMask(GL_FALSE); + glEnable(GL_BLEND); + glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA); + I.particles.use(); + scene.particles->draw(); + glDisable(GL_BLEND); + glDepthMask(GL_TRUE); + glDisable(GL_DEPTH_TEST); + } + + // ---- 7. Depth of field --------------------------------------------------- + GLuint finalHdr = I.post.color(); + if (S.dof) { + glm::vec4 dofParams(f.camParams.z, S.focusRange, S.focusTransition, S.dofMaxRadius); + I.dofHalf.bind(); + I.dofPrep.use(); + I.dofPrep.set("uDof", dofParams); + I.dofPrep.set("uTiltShift", S.tiltShift); + glBindTextureUnit(0, I.post.color()); + glBindTextureUnit(1, I.hdr.depth()); + I.fullscreen(); + + I.dofBlurred.bind(); + I.dofBlur.use(); + I.dofBlur.set("uMaxRadius", S.dofMaxRadius); + I.dofBlur.set("uTexel", glm::vec2(1.0f / float(hw), 1.0f / float(hh))); + glBindTextureUnit(0, I.dofHalf.color()); + I.fullscreen(); + + I.dofOut.bind(); + I.dofComposite.use(); + I.dofComposite.set("uDof", dofParams); + I.dofComposite.set("uTiltShift", S.tiltShift); + glBindTextureUnit(0, I.post.color()); + glBindTextureUnit(1, I.dofBlurred.color()); + glBindTextureUnit(2, I.hdr.depth()); + I.fullscreen(); + finalHdr = I.dofOut.color(); + } + + // ---- 8. Bloom ------------------------------------------------------------ + if (S.bloom) { + GLuint src = finalHdr; + glm::vec2 srcSize(w, h); + I.bloomDown.use(); + for (int i = 0; i < kBloomMips; ++i) { + I.bloom[i].bind(); + I.bloomDown.set("uSrcTexel", 1.0f / srcSize); + I.bloomDown.set("uKaris", i == 0 ? 1 : 0); + glBindTextureUnit(0, src); + I.fullscreen(); + src = I.bloom[i].color(); + srcSize = glm::vec2(I.bloom[i].width(), I.bloom[i].height()); + } + glEnable(GL_BLEND); + glBlendFunc(GL_ONE, GL_ONE); + I.bloomUp.use(); + I.bloomUp.set("uRadius", S.bloomRadius); + for (int i = kBloomMips - 1; i > 0; --i) { + I.bloom[i - 1].bind(); + I.bloomUp.set("uSrcTexel", glm::vec2(1.0f / float(I.bloom[i].width()), 1.0f / float(I.bloom[i].height()))); + glBindTextureUnit(0, I.bloom[i].color()); + I.fullscreen(); + } + glDisable(GL_BLEND); + } + + // ---- 9. Tonemap + grade to the backbuffer -------------------------------- + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glViewport(0, 0, w, h); + Shader& tm = I.tonemap; + tm.use(); + tm.set("uBloomStrength", S.bloom ? S.bloomStrength : 0.0f); + tm.set("uBloomNorm", 1.0f / float(kBloomMips)); // upsample chain sums every mip + tm.set("uExposure", S.exposure); + tm.set("uContrast", S.contrast); + tm.set("uSaturation", S.saturation); + tm.set("uLift", S.lift); + tm.set("uGain", S.gain); + tm.set("uVignette", S.vignette); + tm.set("uChromatic", S.chromaticAberration); + tm.set("uGrain", S.grain); + tm.set("uTimeSeed", std::fmod(time * 61.0f, 1000.0f)); + glBindTextureUnit(0, finalHdr); + glBindTextureUnit(1, I.bloom[0].color()); + I.fullscreen(); + + glEndQuery(GL_TIME_ELAPSED); + I.timerUsed[qi] = true; + I.timerIdx = 1 - qi; +} + +} // namespace hd2d diff --git a/engine/src/Screenshot.cpp b/engine/src/Screenshot.cpp new file mode 100644 index 0000000..2346ba8 --- /dev/null +++ b/engine/src/Screenshot.cpp @@ -0,0 +1,31 @@ +#include "hd2d/Screenshot.h" + +#include "hd2d/GL.h" +#include "hd2d/Log.h" + +#include +#include + +#define STB_IMAGE_WRITE_IMPLEMENTATION +#include + +namespace hd2d { + +bool saveScreenshot(const std::string& path, int width, int height) { + std::vector px(size_t(width) * height * 3); + glBindFramebuffer(GL_READ_FRAMEBUFFER, 0); + glReadBuffer(GL_BACK); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glReadPixels(0, 0, width, height, GL_RGB, GL_UNSIGNED_BYTE, px.data()); + glPixelStorei(GL_PACK_ALIGNMENT, 4); + std::error_code ec; + auto parent = std::filesystem::path(path).parent_path(); + if (!parent.empty()) std::filesystem::create_directories(parent, ec); + stbi_flip_vertically_on_write(1); + bool ok = stbi_write_png(path.c_str(), width, height, 3, px.data(), width * 3) != 0; + if (ok) HD2D_LOG("screenshot saved: %s", path.c_str()); + else HD2D_ERR("failed to write screenshot '%s'", path.c_str()); + return ok; +} + +} // namespace hd2d diff --git a/engine/src/Shader.cpp b/engine/src/Shader.cpp new file mode 100644 index 0000000..63665c4 --- /dev/null +++ b/engine/src/Shader.cpp @@ -0,0 +1,131 @@ +#include "hd2d/Shader.h" + +#include "hd2d/Log.h" + +#include +#include +#include +#include +#include + +namespace hd2d { + +namespace fs = std::filesystem; + +static bool readFile(const fs::path& path, std::string& out) { + std::ifstream f(path, std::ios::binary); + if (!f) return false; + std::stringstream ss; + ss << f.rdbuf(); + out = ss.str(); + return true; +} + +// Expands #include "x" lines recursively; each file is included at most once. +static bool preprocess(const fs::path& path, std::string& out, std::unordered_set& seen) { + std::string src; + if (!readFile(path, src)) { + HD2D_ERR("cannot read shader '%s'", path.string().c_str()); + return false; + } + std::istringstream in(src); + std::string line; + while (std::getline(in, line)) { + auto pos = line.find("#include"); + if (pos != std::string::npos && line.find_first_not_of(" \t") == pos) { + auto a = line.find('"', pos), b = line.rfind('"'); + if (a == std::string::npos || b <= a) { + HD2D_ERR("%s: malformed #include", path.string().c_str()); + return false; + } + fs::path inc = path.parent_path() / line.substr(a + 1, b - a - 1); + std::string key = fs::weakly_canonical(inc).string(); + if (seen.insert(key).second && !preprocess(inc, out, seen)) return false; + out += "\n"; + continue; + } + out += line; + out += '\n'; + } + return true; +} + +static GLuint compile(GLenum type, const std::string& path) { + std::string src; + std::unordered_set seen; + if (!preprocess(path, src, seen)) return 0; + GLuint s = glCreateShader(type); + const char* p = src.c_str(); + glShaderSource(s, 1, &p, nullptr); + glCompileShader(s); + GLint ok = 0; + glGetShaderiv(s, GL_COMPILE_STATUS, &ok); + if (!ok) { + char log[8192]; + glGetShaderInfoLog(s, sizeof log, nullptr, log); + HD2D_ERR("compile '%s':\n%s", path.c_str(), log); + glDeleteShader(s); + return 0; + } + return s; +} + +Shader::~Shader() { + if (program_) glDeleteProgram(program_); +} + +bool Shader::load(const std::string& vertPath, const std::string& fragPath) { + vertPath_ = vertPath; + fragPath_ = fragPath; + return reload(); +} + +bool Shader::reload() { + GLuint vs = compile(GL_VERTEX_SHADER, vertPath_); + GLuint fs = compile(GL_FRAGMENT_SHADER, fragPath_); + if (!vs || !fs) { + if (vs) glDeleteShader(vs); + if (fs) glDeleteShader(fs); + return false; + } + GLuint prog = glCreateProgram(); + glAttachShader(prog, vs); + glAttachShader(prog, fs); + glLinkProgram(prog); + glDeleteShader(vs); + glDeleteShader(fs); + GLint ok = 0; + glGetProgramiv(prog, GL_LINK_STATUS, &ok); + if (!ok) { + char log[8192]; + glGetProgramInfoLog(prog, sizeof log, nullptr, log); + HD2D_ERR("link '%s' + '%s':\n%s", vertPath_.c_str(), fragPath_.c_str(), log); + glDeleteProgram(prog); + return false; + } + if (program_) glDeleteProgram(program_); + program_ = prog; + locations_.clear(); + return true; +} + +GLint Shader::location(const char* name) { + auto it = locations_.find(name); + if (it != locations_.end()) return it->second; + GLint loc = glGetUniformLocation(program_, name); + locations_.emplace(name, loc); + return loc; +} + +void Shader::set(const char* n, int v) { glProgramUniform1i(program_, location(n), v); } +void Shader::set(const char* n, float v) { glProgramUniform1f(program_, location(n), v); } +void Shader::set(const char* n, const glm::vec2& v) { glProgramUniform2fv(program_, location(n), 1, glm::value_ptr(v)); } +void Shader::set(const char* n, const glm::vec3& v) { glProgramUniform3fv(program_, location(n), 1, glm::value_ptr(v)); } +void Shader::set(const char* n, const glm::vec4& v) { glProgramUniform4fv(program_, location(n), 1, glm::value_ptr(v)); } +void Shader::set(const char* n, const glm::ivec2& v) { glProgramUniform2iv(program_, location(n), 1, glm::value_ptr(v)); } +void Shader::set(const char* n, const glm::mat4& v) { + glProgramUniformMatrix4fv(program_, location(n), 1, GL_FALSE, glm::value_ptr(v)); +} +void Shader::set(const char* n, const glm::ivec4& v) { glProgramUniform4iv(program_, location(n), 1, glm::value_ptr(v)); } + +} // namespace hd2d diff --git a/engine/src/Terrain.cpp b/engine/src/Terrain.cpp new file mode 100644 index 0000000..eeefd36 --- /dev/null +++ b/engine/src/Terrain.cpp @@ -0,0 +1,150 @@ +#include "hd2d/Terrain.h" + +#include +#include +#include + +namespace hd2d { + +namespace { + +struct Vertex { + glm::vec3 pos; + glm::vec3 normal; + glm::vec2 uv; + float layer; // texture layer (side faces only; top faces resolve in shader) + float ao; + float kind; // 0 = top, 1 = side, 2 + n = side with an overhang lip from layer n +}; + +uint32_t hash3(int x, int y, int z) { + uint32_t h = uint32_t(x) * 73856093u ^ uint32_t(y) * 19349663u ^ uint32_t(z) * 83492791u; + h ^= h >> 13; + h *= 0x5bd1e995u; + h ^= h >> 15; + return h; +} + +} // namespace + +void Terrain::init(int width, int depth, float levelHeight) { + w_ = width; + d_ = depth; + levelHeight_ = levelHeight; + levels_.assign(size_t(w_) * d_, 0); + mats_.assign(size_t(w_) * d_, 0); +} + +int Terrain::level(int x, int z) const { return inBounds(x, z) ? levels_[size_t(z) * w_ + x] : baseLevel; } +uint8_t Terrain::material(int x, int z) const { return inBounds(x, z) ? mats_[size_t(z) * w_ + x] : 0; } +void Terrain::setLevel(int x, int z, int l) { + if (inBounds(x, z)) levels_[size_t(z) * w_ + x] = l; +} +void Terrain::setMaterial(int x, int z, uint8_t m) { + if (inBounds(x, z)) mats_[size_t(z) * w_ + x] = m; +} +float Terrain::heightAt(float x, float z) const { + return float(level(int(std::floor(x)), int(std::floor(z)))) * levelHeight_; +} + +void Terrain::build() { + std::vector verts; + std::vector idx; + verts.reserve(size_t(w_) * d_ * 8); + + auto quad = [&](const Vertex& a, const Vertex& b, const Vertex& c, const Vertex& d, bool flip) { + uint32_t base = uint32_t(verts.size()); + verts.insert(verts.end(), {a, b, c, d}); + // Flip the diagonal so AO interpolates without anisotropy artifacts. + if (flip) idx.insert(idx.end(), {base + 1, base + 2, base + 3, base + 1, base + 3, base + 0}); + else idx.insert(idx.end(), {base, base + 1, base + 2, base, base + 2, base + 3}); + }; + + const int dirs[4][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + + for (int z = 0; z < d_; ++z) + for (int x = 0; x < w_; ++x) { + const int L = level(x, z); + const float top = float(L) * levelHeight_; + const TerrainMaterial& mat = materials[std::min(material(x, z), kMaxMaterials - 1)]; + + // Top face with per-corner ambient occlusion from higher neighbours. + auto higher = [&](int nx, int nz) { return inBounds(nx, nz) && level(nx, nz) > L ? 1 : 0; }; + auto cornerAO = [&](int cx, int cz) { + int sx = cx ? 1 : -1, sz = cz ? 1 : -1; + int s1 = higher(x + sx, z), s2 = higher(x, z + sz), c = higher(x + sx, z + sz); + int ao = (s1 && s2) ? 0 : 3 - (s1 + s2 + c); + return 0.45f + 0.55f * float(ao) / 3.0f; + }; + Vertex v0{{x, top, z}, {0, 1, 0}, {x, z}, 0, cornerAO(0, 0), 0}; + Vertex v1{{x, top, z + 1}, {0, 1, 0}, {x, z + 1}, 0, cornerAO(0, 1), 0}; + Vertex v2{{x + 1, top, z + 1}, {0, 1, 0}, {x + 1, z + 1}, 0, cornerAO(1, 1), 0}; + Vertex v3{{x + 1, top, z}, {0, 1, 0}, {x + 1, z}, 0, cornerAO(1, 0), 0}; + quad(v0, v1, v2, v3, v0.ao + v2.ao < v1.ao + v3.ao); + + // Cliff faces toward lower neighbours (or the diorama base at the border). + for (int dIdx = 0; dIdx < 4; ++dIdx) { + int nx = x + dirs[dIdx][0], nz = z + dirs[dIdx][1]; + int nl = level(nx, nz); + if (nl >= L) continue; + float bottom = float(nl) * levelHeight_; + glm::vec3 n(float(dirs[dIdx][0]), 0, float(dirs[dIdx][1])); + glm::vec3 bl, br; + switch (dIdx) { + case 0: bl = {x + 1, bottom, z + 1}, br = {x + 1, bottom, z}; break; + case 1: bl = {x, bottom, z}, br = {x, bottom, z + 1}; break; + case 2: bl = {x, bottom, z + 1}, br = {x + 1, bottom, z + 1}; break; + default: bl = {x + 1, bottom, z}, br = {x, bottom, z}; break; + } + glm::vec3 rightDir = glm::normalize(br - bl); + float uL = glm::dot(bl, rightDir), uR = glm::dot(br, rightDir); + float layer = float(mat.sideFirst + int(hash3(x, z, dIdx) % uint32_t(std::max(1, mat.sideCount)))); + float kind = mat.lipLayer >= 0 ? 2.0f + float(mat.lipLayer) : 1.0f; + float height = top - bottom; + bool groundBelow = inBounds(nx, nz); + float aoBottom = groundBelow ? 0.55f : 0.8f; + Vertex a{bl, n, {uL, height}, layer, aoBottom, kind}; + Vertex b{br, n, {uR, height}, layer, aoBottom, kind}; + Vertex c{{br.x, top, br.z}, n, {uR, 0}, layer, 1.0f, kind}; + Vertex d{{bl.x, top, bl.z}, n, {uL, 0}, layer, 1.0f, kind}; + quad(a, b, c, d, false); + } + } + + if (!vao_) { + glCreateVertexArrays(1, &vao_); + glCreateBuffers(1, &vbo_); + glCreateBuffers(1, &ibo_); + } + glNamedBufferData(vbo_, GLsizeiptr(verts.size() * sizeof(Vertex)), verts.data(), GL_STATIC_DRAW); + glNamedBufferData(ibo_, GLsizeiptr(idx.size() * sizeof(uint32_t)), idx.data(), GL_STATIC_DRAW); + indexCount_ = GLsizei(idx.size()); + + glVertexArrayVertexBuffer(vao_, 0, vbo_, 0, sizeof(Vertex)); + glVertexArrayElementBuffer(vao_, ibo_); + auto attr = [&](GLuint loc, GLint n, size_t offset) { + glEnableVertexArrayAttrib(vao_, loc); + glVertexArrayAttribFormat(vao_, loc, n, GL_FLOAT, GL_FALSE, GLuint(offset)); + glVertexArrayAttribBinding(vao_, loc, 0); + }; + attr(0, 3, offsetof(Vertex, pos)); + attr(1, 3, offsetof(Vertex, normal)); + attr(2, 2, offsetof(Vertex, uv)); + attr(3, 3, offsetof(Vertex, layer)); // layer, ao, kind + + if (matTex_) glDeleteTextures(1, &matTex_); + glCreateTextures(GL_TEXTURE_2D, 1, &matTex_); + glTextureStorage2D(matTex_, 1, GL_R8UI, w_, d_); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTextureSubImage2D(matTex_, 0, 0, 0, w_, d_, GL_RED_INTEGER, GL_UNSIGNED_BYTE, mats_.data()); + glPixelStorei(GL_UNPACK_ALIGNMENT, 4); + glTextureParameteri(matTex_, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTextureParameteri(matTex_, GL_TEXTURE_MAG_FILTER, GL_NEAREST); +} + +void Terrain::draw() const { + glBindVertexArray(vao_); + glDrawElements(GL_TRIANGLES, indexCount_, GL_UNSIGNED_INT, nullptr); +} + +} // namespace hd2d diff --git a/engine/src/Texture.cpp b/engine/src/Texture.cpp new file mode 100644 index 0000000..7e567d6 --- /dev/null +++ b/engine/src/Texture.cpp @@ -0,0 +1,146 @@ +#include "hd2d/Texture.h" + +#include "hd2d/Log.h" + +#include +#include + +#define STB_IMAGE_IMPLEMENTATION +#define STBI_ONLY_PNG +#include + +namespace hd2d { + +static void bleedTransparentColors(Image& img) { + const int w = img.width, h = img.height; + std::vector known(size_t(w) * h); + for (int i = 0; i < w * h; ++i) known[i] = img.rgba[i * 4 + 3] > 0; + // A few dilation rounds are enough to cover any filter footprint. + for (int pass = 0; pass < 8; ++pass) { + std::vector next = known; + for (int y = 0; y < h; ++y) + for (int x = 0; x < w; ++x) { + int i = y * w + x; + if (known[i]) continue; + int r = 0, g = 0, b = 0, n = 0; + for (int dy = -1; dy <= 1; ++dy) + for (int dx = -1; dx <= 1; ++dx) { + int nx = x + dx, ny = y + dy; + if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue; + int j = ny * w + nx; + if (!known[j]) continue; + r += img.rgba[j * 4], g += img.rgba[j * 4 + 1], b += img.rgba[j * 4 + 2], ++n; + } + if (n) { + img.rgba[i * 4] = (unsigned char)(r / n); + img.rgba[i * 4 + 1] = (unsigned char)(g / n); + img.rgba[i * 4 + 2] = (unsigned char)(b / n); + next[i] = 1; + } + } + known.swap(next); + } +} + +static bool finishImage(unsigned char* data, Image& out, const char* what) { + if (!data) { + HD2D_ERR("cannot load image '%s': %s", what, stbi_failure_reason()); + return false; + } + out.rgba.assign(data, data + size_t(out.width) * out.height * 4); + stbi_image_free(data); + bleedTransparentColors(out); + return true; +} + +bool loadImage(const std::string& path, Image& out) { + stbi_set_flip_vertically_on_load(1); + int n = 0; + return finishImage(stbi_load(path.c_str(), &out.width, &out.height, &n, 4), out, path.c_str()); +} + +bool decodeImage(const unsigned char* data, size_t size, Image& out, bool flipY) { + stbi_set_flip_vertically_on_load(flipY ? 1 : 0); + int n = 0; + return finishImage(stbi_load_from_memory(data, int(size), &out.width, &out.height, &n, 4), out, ""); +} + +static int mipLevels(int w, int h) { return 1 + int(std::floor(std::log2(float(std::max(w, h))))); } + +Texture& Texture::operator=(Texture&& o) noexcept { + std::swap(id_, o.id_); + width_ = o.width_; + height_ = o.height_; + opaqueBounds_ = o.opaqueBounds_; + return *this; +} + +Texture::~Texture() { + if (id_) glDeleteTextures(1, &id_); +} + +bool Texture::load(const std::string& path, bool repeat) { + Image img; + return loadImage(path, img) && create(img, repeat); +} + +bool Texture::create(const Image& img, bool repeat, float anisotropy) { + width_ = img.width; + height_ = img.height; + + int minX = width_, minY = height_, maxX = -1, maxY = -1; + for (int y = 0; y < height_; ++y) + for (int x = 0; x < width_; ++x) + if (img.rgba[(size_t(y) * width_ + x) * 4 + 3] >= 128) { + minX = std::min(minX, x), maxX = std::max(maxX, x); + minY = std::min(minY, y), maxY = std::max(maxY, y); + } + if (maxX >= 0) + opaqueBounds_ = {float(minX) / width_, float(minY) / height_, float(maxX + 1) / width_, + float(maxY + 1) / height_}; + + if (id_) glDeleteTextures(1, &id_); + glCreateTextures(GL_TEXTURE_2D, 1, &id_); + glTextureStorage2D(id_, mipLevels(width_, height_), GL_SRGB8_ALPHA8, width_, height_); + glTextureSubImage2D(id_, 0, 0, 0, width_, height_, GL_RGBA, GL_UNSIGNED_BYTE, img.rgba.data()); + glGenerateTextureMipmap(id_); + glTextureParameteri(id_, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); + glTextureParameteri(id_, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + GLint wrap = repeat ? GL_REPEAT : GL_CLAMP_TO_EDGE; + glTextureParameteri(id_, GL_TEXTURE_WRAP_S, wrap); + glTextureParameteri(id_, GL_TEXTURE_WRAP_T, wrap); + if (anisotropy > 1.0f) glTextureParameterf(id_, GL_TEXTURE_MAX_ANISOTROPY, anisotropy); + return true; +} + +TextureArray::~TextureArray() { + if (id_) glDeleteTextures(1, &id_); +} + +bool TextureArray::load(const std::vector& paths) { + std::vector images(paths.size()); + for (size_t i = 0; i < paths.size(); ++i) { + if (!loadImage(paths[i], images[i])) return false; + if (images[i].width != images[0].width || images[i].height != images[0].height) { + HD2D_ERR("texture array layer '%s' size mismatch", paths[i].c_str()); + return false; + } + } + width_ = images[0].width; + height_ = images[0].height; + layers_ = int(images.size()); + if (id_) glDeleteTextures(1, &id_); + glCreateTextures(GL_TEXTURE_2D_ARRAY, 1, &id_); + glTextureStorage3D(id_, mipLevels(width_, height_), GL_SRGB8_ALPHA8, width_, height_, layers_); + for (int i = 0; i < layers_; ++i) + glTextureSubImage3D(id_, 0, 0, 0, i, width_, height_, 1, GL_RGBA, GL_UNSIGNED_BYTE, images[i].rgba.data()); + glGenerateTextureMipmap(id_); + glTextureParameteri(id_, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); + glTextureParameteri(id_, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTextureParameteri(id_, GL_TEXTURE_WRAP_S, GL_REPEAT); + glTextureParameteri(id_, GL_TEXTURE_WRAP_T, GL_REPEAT); + glTextureParameterf(id_, GL_TEXTURE_MAX_ANISOTROPY, 8.0f); + return true; +} + +} // namespace hd2d diff --git a/engine/src/Water.cpp b/engine/src/Water.cpp new file mode 100644 index 0000000..ee05c79 --- /dev/null +++ b/engine/src/Water.cpp @@ -0,0 +1,94 @@ +#include "hd2d/Water.h" + +#include "hd2d/Terrain.h" + +#include +#include +#include + +namespace hd2d { + +Water::~Water() { + if (vbo_) glDeleteBuffers(1, &vbo_); + if (ibo_) glDeleteBuffers(1, &ibo_); + if (vao_) glDeleteVertexArrays(1, &vao_); + if (shoreTex_) glDeleteTextures(1, &shoreTex_); +} + +void Water::buildShoreField(const Terrain& terrain, int texelsPerUnit) { + constexpr float kMaxDist = 4.0f; + int tw = std::max(1, int(std::ceil((max_.x - min_.x) * float(texelsPerUnit)))); + int th = std::max(1, int(std::ceil((max_.y - min_.y) * float(texelsPerUnit)))); + + // Land tiles (top above the water) that can influence the field. + std::vector land; + int x0 = int(std::floor(min_.x - kMaxDist)), x1 = int(std::ceil(max_.x + kMaxDist)); + int z0 = int(std::floor(min_.y - kMaxDist)), z1 = int(std::ceil(max_.y + kMaxDist)); + for (int z = z0; z <= z1; ++z) + for (int x = x0; x <= x1; ++x) + if (terrain.inBounds(x, z) && float(terrain.level(x, z)) * terrain.levelHeight() > level_) + land.emplace_back(float(x), float(z)); + + std::vector dist(size_t(tw) * th, kMaxDist); + for (int j = 0; j < th; ++j) + for (int i = 0; i < tw; ++i) { + glm::vec2 p(min_.x + (float(i) + 0.5f) / float(texelsPerUnit), min_.y + (float(j) + 0.5f) / float(texelsPerUnit)); + float best = kMaxDist; + for (const glm::vec2& t : land) { // distance to the tile's unit square + glm::vec2 d = glm::max(glm::max(t - p, p - (t + 1.0f)), glm::vec2(0.0f)); + best = std::min(best, glm::length(d)); + } + dist[size_t(j) * tw + i] = best; + } + + if (shoreTex_) glDeleteTextures(1, &shoreTex_); + glCreateTextures(GL_TEXTURE_2D, 1, &shoreTex_); + glTextureStorage2D(shoreTex_, 1, GL_R16F, tw, th); + glTextureSubImage2D(shoreTex_, 0, 0, 0, tw, th, GL_RED, GL_FLOAT, dist.data()); + glTextureParameteri(shoreTex_, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTextureParameteri(shoreTex_, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTextureParameteri(shoreTex_, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTextureParameteri(shoreTex_, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); +} + +void Water::build(glm::vec2 minXZ, glm::vec2 maxXZ, float level, float cellSize) { + level_ = level; + min_ = minXZ; + max_ = maxXZ; + int nx = std::max(1, int(std::ceil((maxXZ.x - minXZ.x) / cellSize))); + int nz = std::max(1, int(std::ceil((maxXZ.y - minXZ.y) / cellSize))); + std::vector verts; + verts.reserve(size_t(nx + 1) * (nz + 1)); + for (int z = 0; z <= nz; ++z) + for (int x = 0; x <= nx; ++x) + verts.emplace_back(minXZ.x + (maxXZ.x - minXZ.x) * float(x) / float(nx), level, + minXZ.y + (maxXZ.y - minXZ.y) * float(z) / float(nz)); + std::vector idx; + idx.reserve(size_t(nx) * nz * 6); + for (int z = 0; z < nz; ++z) + for (int x = 0; x < nx; ++x) { + uint32_t a = uint32_t(z * (nx + 1) + x), b = a + 1, c = a + uint32_t(nx + 1), d = c + 1; + idx.insert(idx.end(), {a, c, d, a, d, b}); // CCW seen from above + } + + if (!vao_) { + glCreateVertexArrays(1, &vao_); + glCreateBuffers(1, &vbo_); + glCreateBuffers(1, &ibo_); + } + glNamedBufferData(vbo_, GLsizeiptr(verts.size() * sizeof(glm::vec3)), verts.data(), GL_STATIC_DRAW); + glNamedBufferData(ibo_, GLsizeiptr(idx.size() * sizeof(uint32_t)), idx.data(), GL_STATIC_DRAW); + indexCount_ = GLsizei(idx.size()); + glVertexArrayVertexBuffer(vao_, 0, vbo_, 0, sizeof(glm::vec3)); + glVertexArrayElementBuffer(vao_, ibo_); + glEnableVertexArrayAttrib(vao_, 0); + glVertexArrayAttribFormat(vao_, 0, 3, GL_FLOAT, GL_FALSE, 0); + glVertexArrayAttribBinding(vao_, 0, 0); +} + +void Water::draw() const { + glBindVertexArray(vao_); + glDrawElements(GL_TRIANGLES, indexCount_, GL_UNSIGNED_INT, nullptr); +} + +} // namespace hd2d diff --git a/engine/src/Weather.cpp b/engine/src/Weather.cpp new file mode 100644 index 0000000..80015c9 --- /dev/null +++ b/engine/src/Weather.cpp @@ -0,0 +1,135 @@ +#include "hd2d/Weather.h" + +#include "hd2d/Renderer.h" + +#include +#include + +namespace hd2d { + +Weather::Weather() { + // Heavy overcast rain: weak grey sun, flat cool ambient, thick low fog, + // almost no god rays, strong gusty wind. + storm.sunIntensity = 0.7f; + storm.sunColor = {0.78f, 0.82f, 0.9f}; + storm.zenith = {0.30f, 0.34f, 0.40f}; + storm.horizon = {0.42f, 0.45f, 0.50f}; + storm.ground = {0.13f, 0.15f, 0.13f}; + storm.ambient = 0.62f; + storm.fogColor = {0.30f, 0.33f, 0.38f}; + storm.fogDensity = 0.03f; + storm.fogHeightFalloff = 0.22f; + storm.godRays = 0.6f; + storm.shaftStrength = 0.2f; + storm.cookieStrength = 0.0f; + storm.windStrength = 0.2f; + storm.windGustSpeed = 3.0f; + storm.bloomStrength = 0.06f; + storm.saturation = 0.82f; +} + +WeatherLook Weather::capture(const Scene& s, const RenderSettings& r) { + WeatherLook l; + l.sunIntensity = s.sun.intensity; + l.sunColor = s.sun.color; + l.zenith = s.sky.zenith; + l.horizon = s.sky.horizon; + l.ground = s.sky.ground; + l.ambient = s.sky.ambient; + l.fogColor = r.fogColor; + l.fogDensity = r.fogDensity; + l.fogHeightFalloff = r.fogHeightFalloff; + l.godRays = r.godRayIntensity; + l.shaftStrength = r.shaftStrength; + l.cookieStrength = r.cookieStrength; + l.windStrength = r.windStrength; + l.windGustSpeed = r.windGustSpeed; + l.bloomStrength = r.bloomStrength; + l.saturation = r.saturation; + return l; +} + +void Weather::apply(const WeatherLook& l, Scene& s, RenderSettings& r) { + s.sun.intensity = l.sunIntensity; + s.sun.color = l.sunColor; + s.sky.zenith = l.zenith; + s.sky.horizon = l.horizon; + s.sky.ground = l.ground; + s.sky.ambient = l.ambient; + r.fogColor = l.fogColor; + r.fogDensity = l.fogDensity; + r.fogHeightFalloff = l.fogHeightFalloff; + r.godRayIntensity = l.godRays; + r.shaftStrength = l.shaftStrength; + r.cookieStrength = l.cookieStrength; + r.windStrength = l.windStrength; + r.windGustSpeed = l.windGustSpeed; + r.bloomStrength = l.bloomStrength; + r.saturation = l.saturation; +} + +static WeatherLook mixLook(const WeatherLook& a, const WeatherLook& b, float t) { + WeatherLook o; + auto m = [t](auto x, auto y) { return x + (y - x) * t; }; + o.sunIntensity = m(a.sunIntensity, b.sunIntensity); + o.sunColor = m(a.sunColor, b.sunColor); + o.zenith = m(a.zenith, b.zenith); + o.horizon = m(a.horizon, b.horizon); + o.ground = m(a.ground, b.ground); + o.ambient = m(a.ambient, b.ambient); + o.fogColor = m(a.fogColor, b.fogColor); + o.fogDensity = m(a.fogDensity, b.fogDensity); + o.fogHeightFalloff = m(a.fogHeightFalloff, b.fogHeightFalloff); + o.godRays = m(a.godRays, b.godRays); + o.shaftStrength = m(a.shaftStrength, b.shaftStrength); + o.cookieStrength = m(a.cookieStrength, b.cookieStrength); + o.windStrength = m(a.windStrength, b.windStrength); + o.windGustSpeed = m(a.windGustSpeed, b.windGustSpeed); + o.bloomStrength = m(a.bloomStrength, b.bloomStrength); + o.saturation = m(a.saturation, b.saturation); + return o; +} + +void Weather::update(float dt, Scene& scene, RenderSettings& settings) { + // While fully clear, keep tracking the live look so UI tweaks survive. + if (rain_ <= 0.0f && targetRain_ <= 0.0f) { + clear_ = capture(scene, settings); + haveClear_ = true; + } else if (!haveClear_) { + clear_ = capture(scene, settings); + haveClear_ = true; + } + float step = dt / std::max(transitionSeconds, 1e-3f); + rain_ = rain_ < targetRain_ ? std::min(targetRain_, rain_ + step) : std::max(targetRain_, rain_ - step); + // Surfaces soak quickly and dry slowly. + float wetRate = wetness_ < rain_ ? step * 1.5f : step * 0.3f; + wetness_ = wetness_ < rain_ ? std::min(rain_, wetness_ + wetRate) : std::max(rain_, wetness_ - wetRate); + + float k = rain_ * rain_ * (3.0f - 2.0f * rain_); // smoothstep + if (rain_ > 0.0f || targetRain_ > 0.0f) apply(mixLook(clear_, storm, k), scene, settings); + scene.rain = rain_; + scene.wetness = wetness_; + + if (scene.particles) { + ParticleSystem& ps = *scene.particles; + ps.ambientFade = 1.0f - k; + glm::vec2 wd = glm::length(settings.windDirection) > 1e-4f ? glm::normalize(settings.windDirection) + : glm::vec2(1, 0); + ps.wind = glm::vec3(wd.x, 0.0f, wd.y) * settings.windStrength * 10.0f; + + // Ground splashes around the focus point (water gets ripples instead). + splashAccum_ += splashesPerSecond * rain_ * dt; + std::uniform_real_distribution u(-1.0f, 1.0f); + glm::vec3 color = (scene.sky.horizon * 0.8f + glm::vec3(0.15f)) * scene.sky.ambient * 1.6f; + while (splashAccum_ >= 1.0f) { + splashAccum_ -= 1.0f; + glm::vec2 p = glm::vec2(scene.focusPoint.x, scene.focusPoint.z) + glm::vec2(u(rng_), u(rng_)) * splashRadius; + if (!scene.terrain) continue; + float y = scene.terrain->heightAt(p.x, p.y); + if (scene.water && y < scene.water->level()) continue; + ps.burst({p.x, y + 0.02f, p.y}, 2, color, 0.9f, 0.022f, 0.28f); + } + } +} + +} // namespace hd2d diff --git a/engine/src/Window.cpp b/engine/src/Window.cpp new file mode 100644 index 0000000..871065e --- /dev/null +++ b/engine/src/Window.cpp @@ -0,0 +1,97 @@ +#include "hd2d/Window.h" + +#include "hd2d/GL.h" +#include "hd2d/Log.h" + +#include + +#include +#include + +namespace hd2d { + +static void APIENTRY glDebugCallback(GLenum, GLenum type, GLuint, GLenum severity, GLsizei, + const GLchar* message, const void*) { + if (severity == GL_DEBUG_SEVERITY_NOTIFICATION) return; + if (type == GL_DEBUG_TYPE_ERROR || severity == GL_DEBUG_SEVERITY_HIGH) + HD2D_ERR("GL: %s", message); +} + +bool Window::create(int width, int height, const std::string& title, bool vsync, bool resizable) { + glfwSetErrorCallback([](int code, const char* msg) { HD2D_ERR("GLFW %d: %s", code, msg); }); + if (!glfwInit()) return false; + glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4); + glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 5); + glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); + glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE); + glfwWindowHint(GLFW_RESIZABLE, resizable ? GLFW_TRUE : GLFW_FALSE); +#ifndef NDEBUG + glfwWindowHint(GLFW_OPENGL_DEBUG_CONTEXT, GLFW_TRUE); +#endif + window_ = glfwCreateWindow(width, height, title.c_str(), nullptr, nullptr); + if (!window_) { + glfwTerminate(); + return false; + } + glfwMakeContextCurrent(window_); + glfwSwapInterval(vsync ? 1 : 0); + glfwSetWindowUserPointer(window_, this); + glfwSetFramebufferSizeCallback(window_, onFramebufferSize); + glfwSetScrollCallback(window_, onScroll); + glfwGetFramebufferSize(window_, &width_, &height_); + + if (epoxy_has_gl_extension("GL_KHR_debug") || epoxy_gl_version() >= 43) { + glEnable(GL_DEBUG_OUTPUT); + glEnable(GL_DEBUG_OUTPUT_SYNCHRONOUS); + glDebugMessageCallback(glDebugCallback, nullptr); + } + HD2D_LOG("OpenGL %s | %s", glGetString(GL_VERSION), glGetString(GL_RENDERER)); + + double mx, my; + glfwGetCursorPos(window_, &mx, &my); + lastMouse_ = {float(mx), float(my)}; + return true; +} + +void Window::destroy() { + if (window_) glfwDestroyWindow(window_); + window_ = nullptr; + glfwTerminate(); +} + +bool Window::pollEvents() { + resized_ = false; + std::copy(std::begin(keys_), std::end(keys_), std::begin(prevKeys_)); + glfwPollEvents(); + for (int k = GLFW_KEY_SPACE; k <= GLFW_KEY_LAST && k < 512; ++k) + keys_[k] = glfwGetKey(window_, k) == GLFW_PRESS; + double mx, my; + glfwGetCursorPos(window_, &mx, &my); + glm::vec2 m{float(mx), float(my)}; + mouseDelta_ = m - lastMouse_; + lastMouse_ = m; + scrollDelta_ = scrollAccum_; + scrollAccum_ = 0.0f; + return !glfwWindowShouldClose(window_); +} + +void Window::swapBuffers() { glfwSwapBuffers(window_); } + +bool Window::keyDown(int key) const { return key >= 0 && key < 512 && keys_[key]; } +bool Window::keyPressed(int key) const { return keyDown(key) && !prevKeys_[key]; } +bool Window::mouseDown(int button) const { return glfwGetMouseButton(window_, button) == GLFW_PRESS; } +void Window::setTitle(const std::string& title) { glfwSetWindowTitle(window_, title.c_str()); } +void Window::requestClose() { glfwSetWindowShouldClose(window_, GLFW_TRUE); } + +void Window::onFramebufferSize(GLFWwindow* w, int width, int height) { + auto* self = static_cast(glfwGetWindowUserPointer(w)); + self->width_ = width; + self->height_ = height; + self->resized_ = true; +} + +void Window::onScroll(GLFWwindow* w, double, double dy) { + static_cast(glfwGetWindowUserPointer(w))->scrollAccum_ += float(dy); +} + +} // namespace hd2d diff --git a/test_sprite.png b/test_sprite.png new file mode 100644 index 0000000..15d2255 Binary files /dev/null and b/test_sprite.png differ diff --git a/tools/assets.json b/tools/assets.json new file mode 100644 index 0000000..b2165bf --- /dev/null +++ b/tools/assets.json @@ -0,0 +1,78 @@ +{ + "ground_grass": { + "kind": "tiles", + "tile_id": "d69441f7-e60f-4573-bc79-3f32f6594a2f", + "out": "ground_grass.png", + "pick": 0, + "description": "1). lush green meadow grass, dense short blades 2). lush green meadow grass with a few tiny white flowers 3). lush green meadow grass with clover 4). lush green meadow grass, slightly darker", + "tile_type": "square_topdown", + "tile_size": 32, + "tile_view": "top-down", + "outline_mode": "segmentation", + "seed": 11 + }, + "ground_misc": { + "kind": "tiles", + "out": "ground_misc.png", + "tile_id": "a029d543-909a-49ff-a7f2-755cbe44e9fb", + "pick": 0, + "description": "1). packed brown dirt path with small pebbles 2). mossy grey stone cliff wall with cracks 3). wet sand and gravel riverbed 4). grey cobblestone", + "tile_type": "square_topdown", + "tile_size": 32, + "tile_view": "top-down", + "outline_mode": "segmentation", + "seed": 12 + }, + "ground_extra": { + "kind": "tiles", + "tile_id": "1f1fc7e2-bed9-4fff-9dad-da2e69cd6ba2", + "out": "ground_extra.png", + "pick": 0, + "description": "1). smooth packed earth footpath, warm brown soil with scattered tiny pebbles, no stripes 2). earthen cliff face, layered brown soil and embedded stones 3). fine pale sand riverbed with small pebbles 4). dark forest floor soil with moss", + "tile_type": "square_topdown", + "tile_size": 32, + "tile_view": "top-down", + "outline_mode": "segmentation", + "seed": 13 + }, + "tall_grass": { + "out": "tall_grass.png", + "description": "clump of tall wild grass, long pointed dark green and light green blades, pokemon style tall grass patch, game sprite", + "image_size": { + "width": 64, + "height": 64 + }, + "no_background": true, + "view": "side", + "outline": "selective outline", + "shading": "detailed shading", + "detail": "highly detailed", + "seed": 21 + }, + "grass_tuft": { + "out": "grass_tuft.png", + "description": "small tuft of short green grass blades, game sprite", + "image_size": { + "width": 32, + "height": 32 + }, + "no_background": true, + "view": "side", + "outline": "lineless", + "shading": "medium shading", + "seed": 22 + }, + "flowers": { + "out": "flowers.png", + "description": "small cluster of wildflowers with white and yellow petals on green stems, game sprite", + "image_size": { + "width": 32, + "height": 32 + }, + "no_background": true, + "view": "side", + "outline": "selective outline", + "shading": "medium shading", + "seed": 23 + } +} diff --git a/tools/blender_scenery.py b/tools/blender_scenery.py new file mode 100644 index 0000000..c17deef --- /dev/null +++ b/tools/blender_scenery.py @@ -0,0 +1,527 @@ +"""Builds the HD-2D scenery models (trees, bushes, rocks) procedurally in +Blender and exports one .glb per model into assets/models/. + + blender -b -P tools/blender_scenery.py # headless + # or, inside a running Blender (e.g. via the Blender MCP): + exec(open("/tools/blender_scenery.py").read()) + +Textures come from tools/scenery_textures.py (assets/models/src/*.png) and are +embedded into the .glb files. + +Conventions the engine relies on (see engine/src/Model.cpp): + * COLOR_0.rgb linear tint / baked ambient occlusion (multiplies the texture) + * COLOR_0.a wind weight (0 = rigid, 1 = fully swaying foliage) + * materials named "foliage*" are alpha-tested, double-sided and translucent + * custom normals on foliage point away from the canopy centre, which gives + the soft, rounded shading of stylized trees instead of flat cards +""" +import math +import os +import random + +import bpy +import mathutils +from mathutils import Vector + +REPO = os.environ.get("HD2D_REPO") or os.path.dirname(os.path.dirname(os.path.abspath( + __file__ if "__file__" in globals() else "/home/gabe/Documents/hd_2d_engine/tools/x"))) +SRC = os.path.join(REPO, "assets", "models", "src") +OUT = os.path.join(REPO, "assets", "models") + + +# ----------------------------------------------------------------- builder -- +class MeshBuilder: + """Accumulates faces with per-corner uv / color / normal and material ids.""" + + def __init__(self): + self.verts, self.faces, self.uvs, self.cols, self.nrms, self.mats = [], [], [], [], [], [] + + def face(self, pts, uvs, cols, nrms, mat): + base = len(self.verts) + self.verts.extend(pts) + self.faces.append(list(range(base, base + len(pts)))) + self.uvs.extend(uvs) + self.cols.extend(cols) + self.nrms.extend(nrms) + self.mats.append(mat) + + def build(self, name, materials, smooth=True): + me = bpy.data.meshes.new(name) + me.from_pydata([tuple(v) for v in self.verts], [], self.faces) + for m in materials: + me.materials.append(m) + me.polygons.foreach_set("material_index", self.mats) + me.polygons.foreach_set("use_smooth", [smooth] * len(self.faces)) + uv = me.uv_layers.new(name="UVMap") + col = me.color_attributes.new("Col", "FLOAT_COLOR", "CORNER") + for li, loop in enumerate(me.loops): # from_pydata keeps corner order + uv.data[li].uv = self.uvs[li] + col.data[li].color = self.cols[li] + me.color_attributes.active_color = col + me.validate() + me.normals_split_custom_set([tuple(n) for n in self.nrms]) + ob = bpy.data.objects.new(name, me) + COLLECTION.objects.link(ob) + return ob + + +def material(name, image, alpha=False, tint_from_vcol=True, emission=0.0): + mat = bpy.data.materials.get(name) or bpy.data.materials.new(name) + mat.use_nodes = True + nt = mat.node_tree + nt.nodes.clear() + out = nt.nodes.new("ShaderNodeOutputMaterial") + bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled") + bsdf.inputs["Roughness"].default_value = 0.85 + tex = nt.nodes.new("ShaderNodeTexImage") + path = os.path.join(SRC, image) + tex.image = bpy.data.images.load(path, check_existing=True) + color = tex.outputs["Color"] + if tint_from_vcol: + vc = nt.nodes.new("ShaderNodeVertexColor") + vc.layer_name = "Col" + mul = nt.nodes.new("ShaderNodeMix") + mul.data_type = "RGBA" + mul.blend_type = "MULTIPLY" + mul.inputs["Factor"].default_value = 1.0 + nt.links.new(color, mul.inputs["A"]) + nt.links.new(vc.outputs["Color"], mul.inputs["B"]) + color = mul.outputs["Result"] + nt.links.new(color, bsdf.inputs["Base Color"]) + if alpha: + cut = nt.nodes.new("ShaderNodeMath") # Round -> exported as alphaMode MASK + cut.operation = "ROUND" + nt.links.new(tex.outputs["Alpha"], cut.inputs[0]) + nt.links.new(cut.outputs[0], bsdf.inputs["Alpha"]) + mat.surface_render_method = "DITHERED" + if emission > 0.0: # exported as emissiveTexture + KHR_materials_emissive_strength + nt.links.new(tex.outputs["Color"], bsdf.inputs["Emission Color"]) + bsdf.inputs["Emission Strength"].default_value = emission + mat.use_backface_culling = not alpha # exported as glTF doubleSided + nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"]) + return mat + + +def frame(t): + t = t.normalized() + up = Vector((0, 0, 1)) if abs(t.z) < 0.95 else Vector((1, 0, 0)) + a = t.cross(up).normalized() + return a, t.cross(a).normalized() + + +def tube(mb, pts, radii, mat, sides=10, ao=(0.6, 1.0), wind=lambda p: 0.0, texel=1.2): + """Tapered tube along a polyline, with cylindrical bark UVs.""" + rings, length = [], 0.0 + circ = 2 * math.pi * sum(radii) / len(radii) + urep = max(1, round(circ / 0.7)) + for i, p in enumerate(pts): + t = (pts[min(i + 1, len(pts) - 1)] - pts[max(i - 1, 0)]) + a, b = frame(t) + if i: + length += (p - pts[i - 1]).length + ring = [] + for j in range(sides + 1): + ang = j / sides * math.tau + n = a * math.cos(ang) + b * math.sin(ang) + ring.append((p + n * radii[i], n, (j / sides * urep, length / texel))) + rings.append(ring) + for i in range(len(rings) - 1): + k0 = ao[0] + (ao[1] - ao[0]) * min(1.0, i / max(1, len(rings) - 2)) + k1 = ao[0] + (ao[1] - ao[0]) * min(1.0, (i + 1) / max(1, len(rings) - 2)) + for j in range(sides): + quad = [rings[i][j], rings[i][j + 1], rings[i + 1][j + 1], rings[i + 1][j]] + ks = [k0, k0, k1, k1] + mb.face([q[0] for q in quad], [q[2] for q in quad], + [(k, k, k, wind(q[0])) for q, k in zip(quad, ks)], [q[1] for q in quad], mat) + + +def ico(subdiv): + """Unit icosphere vertices + triangles.""" + t = (1 + 5 ** 0.5) / 2 + v = [Vector(x).normalized() for x in [(-1, t, 0), (1, t, 0), (-1, -t, 0), (1, -t, 0), (0, -1, t), (0, 1, t), + (0, -1, -t), (0, 1, -t), (t, 0, -1), (t, 0, 1), (-t, 0, -1), (-t, 0, 1)]] + f = [(0, 11, 5), (0, 5, 1), (0, 1, 7), (0, 7, 10), (0, 10, 11), (1, 5, 9), (5, 11, 4), (11, 10, 2), (10, 7, 6), + (7, 1, 8), (3, 9, 4), (3, 4, 2), (3, 2, 6), (3, 6, 8), (3, 8, 9), (4, 9, 5), (2, 4, 11), (6, 2, 10), + (8, 6, 7), (9, 8, 1)] + for _ in range(subdiv): + cache, nf = {}, [] + + def mid(a, b): + key = (min(a, b), max(a, b)) + if key not in cache: + cache[key] = len(v) + v.append(((v[a] + v[b]) / 2).normalized()) + return cache[key] + + for a, b, c in f: + ab, bc, ca = mid(a, b), mid(b, c), mid(c, a) + nf += [(a, ab, ca), (b, bc, ab), (c, ca, bc), (ab, bc, ca)] + f = nf + return v, f + + +def blend_normal(p, centre, local): + return ((p - centre).normalized() * 0.65 + local * 0.35).normalized() + + +def clump(mb, rng, c, r, canopy_c, canopy_r, mat_core, mat_cards, cards_per_r2=42, card_scale=1.0, + wind=1.0, ao_floor=0.45, core=True): + """One leafy lump: an opaque leaf-mass core plus alpha-tested leaf cards.""" + def ao_at(p): + h = (p.z - (canopy_c.z - canopy_r)) / (2 * canopy_r) + return ao_floor + (1 - ao_floor) * max(0.0, min(1.0, h)) ** 0.8 + + if core: + v, f = ico(2) + for tri in f: + ps = [c + v[i] * r * 0.8 for i in tri] + uvs = [(math.atan2(v[i].y, v[i].x) / math.pi * 1.5, v[i].z * 1.5) for i in tri] + if max(u for u, _ in uvs) - min(u for u, _ in uvs) > 1.5: # fix the atan2 seam + uvs = [(u + 3.0 if u < 0 else u, w) for u, w in uvs] + cols = [(ao_at(p) * 0.75,) * 3 + (wind,) for p in ps] + mb.face(ps, uvs, cols, [blend_normal(p, canopy_c, v[i]) for p, i in zip(ps, tri)], mat_core) + + n = int(cards_per_r2 * r * r) + 6 + for _ in range(n): + d = Vector((rng.gauss(0, 1), rng.gauss(0, 1), rng.gauss(0, 1) * 0.8 + 0.35)).normalized() + p = c + d * r * rng.uniform(0.72, 1.0) + cn = (d + Vector((rng.gauss(0, 0.5), rng.gauss(0, 0.5), rng.gauss(0, 0.5)))).normalized() + a, b = frame(cn) + roll = rng.random() * math.tau + a, b = a * math.cos(roll) + b * math.sin(roll), b * math.cos(roll) - a * math.sin(roll) + s = r * rng.uniform(0.85, 1.2) * card_scale + corners = [p - a * s / 2 - b * s / 2, p + a * s / 2 - b * s / 2, p + a * s / 2 + b * s / 2, p - a * s / 2 + b * s / 2] + tint = rng.uniform(0.88, 1.1) + mb.face(corners, [(0, 0), (1, 0), (1, 1), (0, 1)], + [(ao_at(q) * tint,) * 3 + (wind,) for q in corners], [blend_normal(q, canopy_c, d) for q in corners], + mat_cards) + + +# ------------------------------------------------------------------ models -- +def make_oak(name, seed): + rng = random.Random(seed) + mb = MeshBuilder() + trunk_h = rng.uniform(2.4, 2.9) + lean = Vector((rng.uniform(-0.25, 0.25), rng.uniform(-0.25, 0.25), 0)) + pts = [Vector((0, 0, -0.3)), Vector((0, 0, 0.0)), Vector((0, 0, 0.25))] + pts += [Vector((0, 0, z)) + lean * (z / trunk_h) ** 2 for z in (0.8, 1.4, 2.0, trunk_h)] + radii = [0.52, 0.42, 0.33, 0.28, 0.26, 0.24, 0.2] + wind_trunk = lambda p: max(0.0, min(1.0, (p.z - 1.2) / 4.0)) * 0.25 + tube(mb, pts, radii, 0, ao=(0.45, 1.0), wind=wind_trunk) + + top = pts[-1] + canopy_c = top + Vector((0, 0, 1.25)) + canopy_r = 2.2 + clumps = [(canopy_c, 1.45), (canopy_c + Vector((0, 0, 0.95)), 1.05)] + n = rng.randint(5, 7) + for k in range(n): + ang = k / n * math.tau + rng.uniform(-0.3, 0.3) + dist = rng.uniform(1.05, 1.45) + c = canopy_c + Vector((math.cos(ang) * dist, math.sin(ang) * dist, rng.uniform(-0.65, 0.35))) + clumps.append((c, rng.uniform(0.85, 1.15))) + # A branch from the trunk top toward each outer clump. + start = top + Vector((0, 0, rng.uniform(-0.6, 0.0))) + mid = start.lerp(c, 0.5) + Vector((0, 0, 0.25)) + tube(mb, [start, mid, start.lerp(c, 0.85)], [0.14, 0.09, 0.05], 0, sides=6, ao=(0.7, 0.9), wind=wind_trunk) + for c, r in clumps: + clump(mb, rng, c, r, canopy_c, canopy_r, 1, 2) + return mb.build(name, [MAT["bark"], MAT["foliage_core"], MAT["foliage_oak"]]) + + +def make_pine(name, seed): + rng = random.Random(seed) + mb = MeshBuilder() + height = rng.uniform(5.8, 6.6) + pts = [Vector((0, 0, z)) for z in (-0.3, 0.3, 1.5, 3.0, 4.5, height)] + radii = [0.3, 0.22, 0.18, 0.13, 0.08, 0.02] + tube(mb, pts, radii, 0, sides=8, ao=(0.5, 0.9), wind=lambda p: max(0.0, (p.z - 1.0) / height) * 0.3) + + # Dense core cone so gaps between fronds read as deep needles, not sky. + sides, tiers = 12, 8 + z0, z1 = 0.9, height - 0.1 + for i in range(tiers): + za, zb = z0 + (z1 - z0) * i / tiers, z0 + (z1 - z0) * (i + 1) / tiers + ra, rb = 1.15 * (1 - i / tiers) + 0.05, 1.15 * (1 - (i + 1) / tiers) + 0.05 + for j in range(sides): + a0, a1 = j / sides * math.tau, (j + 1) / sides * math.tau + quad = [Vector((math.cos(a0) * ra, math.sin(a0) * ra, za)), Vector((math.cos(a1) * ra, math.sin(a1) * ra, za)), + Vector((math.cos(a1) * rb, math.sin(a1) * rb, zb)), Vector((math.cos(a0) * rb, math.sin(a0) * rb, zb))] + uvs = [(j / sides * 3, za / 1.5), ((j + 1) / sides * 3, za / 1.5), ((j + 1) / sides * 3, zb / 1.5), (j / sides * 3, zb / 1.5)] + nrm = [Vector((q.x, q.y, 0.6)).normalized() for q in quad] + k = [0.5 + 0.4 * (q.z / height) for q in quad] + mb.face(quad, uvs, [(kk * 0.6, kk * 0.9, kk * 0.8, 0.5) for kk in k], nrm, 1) + + h = 0.85 + tier = 0 + while h < height - 0.3: + frac = (h - 0.85) / (height - 1.1) + length = 2.5 * (1 - frac) ** 0.95 + 0.45 + count = 11 if frac < 0.7 else 7 + for k in range(count): + ang = k / count * math.tau + tier * 0.55 + rng.uniform(-0.2, 0.2) + out = Vector((math.cos(ang), math.sin(ang), 0)) + side = Vector((-out.y, out.x, 0)) + roll = rng.uniform(-0.35, 0.35) + across = (side * math.cos(roll) + Vector((0, 0, 1)) * math.sin(roll)) * length * 0.32 + base = Vector((0, 0, h + rng.uniform(-0.08, 0.08))) + droop = rng.uniform(0.25, 0.4) + segs = [base + out * length * t + Vector((0, 0, length * (0.12 * t - droop * t * t))) for t in (0, 0.34, 0.67, 1.0)] + # Flat card seen from above + a narrower vertical cross card for volume from the side. + cross = (out.cross(across).normalized()) * length * 0.2 + for spread, kc in ((across, 1.0), (cross, 0.85)): + for s in range(3): + t0, t1 = s / 3, (s + 1) / 3 + quad = [segs[s] - spread, segs[s + 1] - spread, segs[s + 1] + spread, segs[s] + spread] + uvs = [(t0, 0), (t1, 0), (t1, 1), (t0, 1)] + up = Vector((0, 0, 1)) + nrm = [(out * 0.45 + up * 0.9).normalized()] * 4 + ks = [(0.55 + 0.45 * t) * kc for t in (t0, t1, t1, t0)] + kh = 0.75 + 0.25 * frac + w = [t * (0.5 + 0.5 * frac) for t in (t0, t1, t1, t0)] + mb.face(quad, uvs, [(kk * kh, kk * kh, kk * kh, ww) for kk, ww in zip(ks, w)], nrm, 2) + h += rng.uniform(0.32, 0.4) + tier += 1 + return mb.build(name, [MAT["bark"], MAT["foliage_core"], MAT["foliage_pine"]]) + + +def make_bush(name, seed): + rng = random.Random(seed) + mb = MeshBuilder() + centre = Vector((0, 0, 0.42)) + clumps = [(centre, 0.55)] + n = rng.randint(3, 5) + for k in range(n): + ang = k / n * math.tau + rng.uniform(-0.4, 0.4) + d = rng.uniform(0.35, 0.5) + clumps.append((Vector((math.cos(ang) * d, math.sin(ang) * d, rng.uniform(0.28, 0.45))), rng.uniform(0.36, 0.5))) + for c, r in clumps: + clump(mb, rng, c, r, centre, 0.75, 0, 1, cards_per_r2=60, card_scale=1.25, wind=0.35, ao_floor=0.35) + return mb.build(name, [MAT["foliage_core"], MAT["foliage_bush"]]) + + +def make_rock(name, seed): + rng = random.Random(seed) + mb = MeshBuilder() + v, f = ico(2) + off = Vector((rng.random() * 100, rng.random() * 100, rng.random() * 100)) + scale = Vector((rng.uniform(0.9, 1.15), rng.uniform(0.7, 0.9), rng.uniform(0.5, 0.7))) * 0.55 + pos = [] + for p in v: + k = 1 + 0.34 * mathutils.noise.noise(p * 1.3 + off) + 0.1 * mathutils.noise.noise(p * 3.5 + off) + q = Vector((p.x * scale.x, p.y * scale.y, p.z * scale.z)) * k + q.z = max(q.z, -0.12) + pos.append(q) + # Smooth vertex normals from the displaced shape. + nrm = [Vector((0, 0, 0)) for _ in pos] + for a, b, c in f: + fn = (pos[b] - pos[a]).cross(pos[c] - pos[a]) + for i in (a, b, c): + nrm[i] += fn + nrm = [n.normalized() for n in nrm] + for tri in f: + ps = [pos[i] for i in tri] + fn = (ps[1] - ps[0]).cross(ps[2] - ps[0]).normalized() + ax = max(range(3), key=lambda i: abs(fn[i])) # box projection + uvs = [((p.y, p.z) if ax == 0 else (p.x, p.z) if ax == 1 else (p.x, p.y)) for p in ps] + uvs = [(u * 2.2, w * 2.2) for u, w in uvs] + cols = [] + for i in tri: + nz = nrm[i].z + moss = max(0.0, min(1.0, (nz - 0.45) / 0.4)) * (0.6 + 0.4 * mathutils.noise.noise(pos[i] * 3 + off)) + ao = 0.55 + 0.45 * max(0.0, min(1.0, (pos[i].z + 0.12) / 0.4)) + tint = Vector((1, 1, 1)).lerp(Vector((0.55, 1.05, 0.32)), max(0.0, min(1.0, moss))) + cols.append((tint.x * ao, tint.y * ao, tint.z * ao, 0.0)) + # Mostly faceted: chunky stylized stone that still catches soft light. + mb.face(ps, uvs, cols, [(fn * 0.65 + nrm[i] * 0.35).normalized() for i in tri], 0) + return mb.build(name, [MAT["rock"]]) + + +# ----------------------------------------------------------------- bridge -- +# Deck geometry; demo/DemoScene.cpp mirrors these numbers for walking on it. +BRIDGE_LEN, BRIDGE_W, BRIDGE_RISE = 17.0, 2.6, 0.7 +WALL_T, WALL_H = 0.28, 0.5 + + +def deck_z(x): + u = max(-1.0, min(1.0, 2 * x / BRIDGE_LEN)) + return 0.05 + BRIDGE_RISE * (1 - u * u) + + +def ao_stone(z): + return 0.5 + 0.5 * max(0.0, min(1.0, (z + 0.6) / 1.6)) + + +def quad_face(mb, pts, normal, mat, uv_axes, uv_scale, ao=ao_stone): + """Planar quad with world-projected UVs (uv_axes picks two of x/y/z).""" + uvs = [(p[uv_axes[0]] / uv_scale, p[uv_axes[1]] / uv_scale) for p in pts] + mb.face(pts, uvs, [(ao(p.z),) * 3 + (0.0,) for p in pts], [normal] * 4, mat) + + +def box(mb, lo, hi, mat, uv_scale=2.0): + x0, y0, z0 = lo + x1, y1, z1 = hi + V = lambda x, y, z: Vector((x, y, z)) + quad_face(mb, [V(x0, y0, z1), V(x1, y0, z1), V(x1, y1, z1), V(x0, y1, z1)], Vector((0, 0, 1)), mat, (0, 1), uv_scale) + quad_face(mb, [V(x0, y1, z0), V(x1, y1, z0), V(x1, y0, z0), V(x0, y0, z0)], Vector((0, 0, -1)), mat, (0, 1), uv_scale) + quad_face(mb, [V(x0, y0, z0), V(x1, y0, z0), V(x1, y0, z1), V(x0, y0, z1)], Vector((0, -1, 0)), mat, (0, 2), uv_scale) + quad_face(mb, [V(x1, y1, z0), V(x0, y1, z0), V(x0, y1, z1), V(x1, y1, z1)], Vector((0, 1, 0)), mat, (0, 2), uv_scale) + quad_face(mb, [V(x0, y1, z0), V(x0, y0, z0), V(x0, y0, z1), V(x0, y1, z1)], Vector((-1, 0, 0)), mat, (1, 2), uv_scale) + quad_face(mb, [V(x1, y0, z0), V(x1, y1, z0), V(x1, y1, z1), V(x1, y0, z1)], Vector((1, 0, 0)), mat, (1, 2), uv_scale) + + +def lathe(mb, profile, centre, mat, sides=16, planar=False): + """Surface of revolution around +Z from (radius, z) pairs (bottom to top).""" + for i in range(len(profile) - 1): + (r0, z0), (r1, z1) = profile[i], profile[i + 1] + slope = Vector((z1 - z0, 0, -(r1 - r0))).normalized() # outward normal in the r/z plane + for j in range(sides): + a0, a1 = j / sides * math.tau, (j + 1) / sides * math.tau + corners = ((r0, z0, a0), (r0, z0, a1), (r1, z1, a1), (r1, z1, a0)) + pts = [centre + Vector((math.cos(a) * r, math.sin(a) * r, z)) for r, z, a in corners] + nrm = [Vector((math.cos(a) * slope.x, math.sin(a) * slope.x, slope.z)) for _, _, a in corners] + uvs = ([((p - centre).x * 1.5, (p - centre).y * 1.5) for p in pts] if planar + else [(a / math.tau * 3, z * 2) for _, z, a in corners]) + mb.face(pts, uvs, [(1.0, 1.0, 1.0, 0.0)] * 4, nrm, mat) + + +def make_bridge(name, seed): + """Arched stone bridge along +X with parapets, posts, entrance pillars and + two braziers at the -X entrance. Fire sockets: fire_0, fire_1.""" + mb = MeshBuilder() + STONE, DECK, IRON, COALS = 0, 1, 2, 3 + L, W = BRIDGE_LEN, BRIDGE_W + piers = [-L / 4, 0.0, L / 4] + pier_half = 0.45 + abut = L / 2 - 1.3 + + def under_z(x): + if abs(x) >= abut: + return -0.6 + for p in piers: + if abs(x - p) <= pier_half: + return -2.6 + edges = [-abut] + [e for p in piers for e in (p - pier_half, p + pier_half)] + [abut] + for a, b in zip(edges[0::2], edges[1::2]): + if a <= x <= b: + u = (x - (a + b) / 2) / ((b - a) / 2) + spring, top = -0.9, deck_z(x) - 0.42 + return spring + (top - spring) * math.sqrt(max(0.0, 1 - u * u)) + return -0.6 + + xs = [-L / 2 + i * 0.1 for i in range(int(round(L / 0.1)) + 1)] + yo, yi = W / 2, W / 2 - WALL_T + V = lambda x, y, z: Vector((x, y, z)) + for x0, x1 in zip(xs[:-1], xs[1:]): + d0, d1 = deck_z(x0), deck_z(x1) + u0, u1 = under_z(x0), under_z(x1) + t0, t1 = d0 + WALL_H, d1 + WALL_H + up = Vector((-(d1 - d0), 0, x1 - x0)).normalized() + # Deck (cobbles) between the parapets. + quad_face(mb, [V(x0, -yi, d0), V(x1, -yi, d1), V(x1, yi, d1), V(x0, yi, d0)], up, DECK, (0, 1), 2.2, + ao=lambda z: 1.0) + for s in (-1, 1): + # Outer face: from the arch soffit up to the parapet top (CCW seen from outside). + pts = [V(x0, s * yo, u0), V(x1, s * yo, u1), V(x1, s * yo, t1), V(x0, s * yo, t0)] + quad_face(mb, pts if s < 0 else pts[::-1], V(0, s, 0), STONE, (0, 2), 2.0) + # Inner parapet face + parapet cap. + pts = [V(x0, s * yi, d0), V(x1, s * yi, d1), V(x1, s * yi, t1), V(x0, s * yi, t0)] + quad_face(mb, pts if s > 0 else pts[::-1], V(0, -s, 0), STONE, (0, 2), 2.0, ao=lambda z: 0.8) + cap = [V(x0, s * yi, t0), V(x1, s * yi, t1), V(x1, s * yo, t1), V(x0, s * yo, t0)] + quad_face(mb, cap if s > 0 else cap[::-1], up, STONE, (0, 1), 2.0, ao=lambda z: 1.0) + # Arch soffit, seen in the water reflection. + quad_face(mb, [V(x0, yo, u0), V(x1, yo, u1), V(x1, -yo, u1), V(x0, -yo, u0)], V(0, 0, -1), STONE, (0, 1), 2.0, + ao=lambda z: 0.45) + for s in (-1, 1): # end faces + x = s * L / 2 + pts = [V(x, -yo, -0.6), V(x, yo, -0.6), V(x, yo, deck_z(x) + WALL_H), V(x, -yo, deck_z(x) + WALL_H)] + quad_face(mb, pts if s > 0 else pts[::-1], V(s, 0, 0), STONE, (1, 2), 2.0) + + # Cutwaters on the piers, posts along the parapets. + for p in piers: + box(mb, (p - pier_half - 0.05, -yo - 0.22, -2.6), (p + pier_half + 0.05, yo + 0.22, -0.05), STONE) + box(mb, (p - pier_half - 0.1, -yo - 0.28, -0.05), (p + pier_half + 0.1, yo + 0.28, 0.08), STONE) + for k in range(1, 6): + x = -L / 2 + k * L / 6 + for s in (-1, 1): + y0, y1 = sorted((s * (yi - 0.04), s * (yo + 0.06))) + box(mb, (x - 0.2, y0, deck_z(x) - 0.1), (x + 0.2, y1, deck_z(x) + WALL_H + 0.22), STONE) + box(mb, (x - 0.25, y0 - 0.04, deck_z(x) + WALL_H + 0.22), (x + 0.25, y1 + 0.04, deck_z(x) + WALL_H + 0.3), STONE) + + # Entrance pillars at both ends; braziers on the -X pair. + fires = [] + for end in (-1, 1): + x = end * (L / 2 - 0.35) + for s in (-1, 1): + y = s * (yo + 0.1) + box(mb, (x - 0.38, y - 0.38, -0.6), (x + 0.38, y + 0.38, 1.25), STONE) + box(mb, (x - 0.46, y - 0.46, 1.25), (x + 0.46, y + 0.46, 1.4), STONE) + if end < 0: + c = Vector((x, y, 1.4)) + lathe(mb, [(0.16, 0.0), (0.1, 0.08), (0.08, 0.2), (0.14, 0.26), (0.34, 0.38), (0.42, 0.52), (0.38, 0.54), + (0.37, 0.5)], c, IRON) + lathe(mb, [(0.37, 0.5), (0.0, 0.46)], c, COALS, planar=True) # glowing coal bed + fires.append(c + Vector((0, 0, 0.5))) + ob = mb.build(name, [MAT["stone"], MAT["cobble"], MAT["iron"], MAT["coals"]], smooth=False) + for i, p in enumerate(fires): + socket = bpy.data.objects.new(f"fire_{i}", None) + socket.empty_display_size = 0.3 + socket.location = p + COLLECTION.objects.link(socket) + socket.parent = ob + return ob + + +# ------------------------------------------------------------------- main -- +def export(objects, path): + """One .glb with every model as a named root node, so textures are stored once. + Child empties are exported too: the engine reads them as named sockets.""" + bpy.ops.object.select_all(action="DESELECT") + for ob in objects: + ob.select_set(True) + for child in ob.children: + child.select_set(True) + bpy.context.view_layer.objects.active = objects[0] + bpy.ops.export_scene.gltf(filepath=path, export_format="GLB", use_selection=True, export_yup=True, + export_apply=True, export_normals=True, export_texcoords=True, + export_vertex_color="ACTIVE", export_image_format="AUTO", export_materials="EXPORT") + + +MODELS = [ + ("oak_a", make_oak, 11), ("oak_b", make_oak, 23), ("pine_a", make_pine, 5), ("pine_b", make_pine, 17), + ("bush_a", make_bush, 3), ("bush_b", make_bush, 9), ("rock_a", make_rock, 2), ("rock_b", make_rock, 8), + ("bridge", make_bridge, 0), +] + +# Everything lives in its own collection; nothing else in the open file is touched. +COLLECTION = bpy.data.collections.get("HD2D_Scenery") +if COLLECTION is None: + COLLECTION = bpy.data.collections.new("HD2D_Scenery") + bpy.context.scene.collection.children.link(COLLECTION) +for ob in list(COLLECTION.objects): + me = ob.data + bpy.data.objects.remove(ob, do_unlink=True) + if me and me.users == 0: + bpy.data.meshes.remove(me) + +MAT = { + "bark": material("bark", "bark.png"), + "rock": material("rock", "rock.png"), + "foliage_core": material("foliage_core", "leaves_dense.png"), + "foliage_oak": material("foliage_oak", "leaves_oak.png", alpha=True), + "foliage_bush": material("foliage_bush", "leaves_bush.png", alpha=True), + "foliage_pine": material("foliage_pine", "pine_frond.png", alpha=True), + "stone": material("stone", "stone_blocks.png"), + "cobble": material("cobble", "cobble.png"), + "iron": material("iron", "iron.png"), + "coals": material("coals", "coals.png", emission=2.5), +} + +os.makedirs(OUT, exist_ok=True) +objects = [] +for name, fn, seed in MODELS: + ob = fn(name, seed) + objects.append(ob) + print("built", name, len(ob.data.polygons), "faces") +export(objects, os.path.join(OUT, "scenery.glb")) # all at the origin: the engine uses node names +for i, ob in enumerate(objects): + ob.location.x = i * 6.0 # lay out side by side for previewing in Blender diff --git a/tools/pixellab.py b/tools/pixellab.py new file mode 100755 index 0000000..330b8ee --- /dev/null +++ b/tools/pixellab.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""PixelLab asset pipeline. + +Reads tools/assets.json, generates every asset whose output file is missing +(or all of them with --force / the ones named on the command line), and writes +PNGs into assets/textures/. The API key is read from PIXELLAB_API_KEY in the +environment or from the repo's .env file. + + python3 tools/pixellab.py # generate missing assets + python3 tools/pixellab.py tall_grass # (re)generate named assets + python3 tools/pixellab.py --list # list manifest entries + python3 tools/pixellab.py --balance # show account balance + +Manifest entry kinds: + "image" -> POST /create-image-pixflux (synchronous) + "tiles" -> POST /create-tiles-pro, polls GET /tiles-pro/{id}; writes + _.png for every variation plus for "pick" index +""" +import base64 +import json +import os +import sys +import time +import urllib.error +import urllib.request +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +API = "https://api.pixellab.ai/v2" +MANIFEST = ROOT / "tools" / "assets.json" +OUT_DIR = ROOT / "assets" / "textures" + + +def api_key() -> str: + key = os.environ.get("PIXELLAB_API_KEY") + if key: + return key.strip() + env = ROOT / ".env" + if env.exists(): + for line in env.read_text().splitlines(): + line = line.strip() + if line.startswith("PIXELLAB_API_KEY="): + return line.split("=", 1)[1].strip().strip('"').strip("'") + sys.exit("PIXELLAB_API_KEY not set (environment or .env)") + + +def request(method: str, path: str, body=None, timeout=180): + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request( + API + path, + data=data, + method=method, + headers={ + "Authorization": f"Bearer {api_key()}", + "Content-Type": "application/json", + }, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + return r.status, json.loads(r.read() or b"null") + except urllib.error.HTTPError as e: + return e.code, e.read().decode(errors="replace") + + +def save_b64(b64: str, path: Path): + path.parent.mkdir(parents=True, exist_ok=True) + if "," in b64[:64]: # data URL prefix + b64 = b64.split(",", 1)[1] + path.write_bytes(base64.b64decode(b64)) + + +# Manifest keys consumed by this script, never sent to the API. +LOCAL_KEYS = {"kind", "out", "pick", "tile_id"} + + +def api_body(spec): + return {k: v for k, v in spec.items() if k not in LOCAL_KEYS} + + +def gen_image(name, spec): + body = api_body(spec) + status, res = request("POST", "/create-image-pixflux", body) + if status != 200: + raise RuntimeError(f"{name}: HTTP {status}: {res}") + out = OUT_DIR / spec["out"] + save_b64(res["image"]["base64"], out) + return [out] + + +def gen_tiles(name, spec): + # A manifest "tile_id" re-downloads an existing (already paid for) tile set + # instead of generating a new one. Delete it to regenerate. + tile_id = spec.get("tile_id") + if not tile_id: + body = api_body(spec) + status, res = request("POST", "/create-tiles-pro", body) + if status not in (200, 202): + raise RuntimeError(f"{name}: HTTP {status}: {res}") + tile_id = res["tile_id"] + print(f"[..] {name}: tile_id {tile_id} (add to manifest to reuse)", flush=True) + deadline = time.time() + 600 + while time.time() < deadline: + status, res = request("GET", f"/tiles-pro/{tile_id}") + if status == 423: # still processing + time.sleep(6) + continue + if status != 200: + raise RuntimeError(f"{name}: poll HTTP {status}: {res}") + if res.get("storage_urls"): + break + if res.get("status") == "failed": + raise RuntimeError(f"{name}: generation failed: {res}") + time.sleep(6) + else: + raise RuntimeError(f"{name}: timed out") + stem = Path(spec["out"]).stem + outs = [] + urls = res["storage_urls"] + keys = sorted(urls, key=lambda k: int("".join(c for c in k if c.isdigit()) or 0)) + for i, key in enumerate(keys): + p = OUT_DIR / f"{stem}_{i}.png" + p.parent.mkdir(parents=True, exist_ok=True) + # The storage host rejects urllib's default User-Agent with 403. + dl = urllib.request.Request(urls[key], headers={"User-Agent": "hd2d-asset-pipeline/1.0"}) + with urllib.request.urlopen(dl, timeout=120) as r: + p.write_bytes(r.read()) + outs.append(p) + pick = spec.get("pick", 0) + if outs: + (OUT_DIR / spec["out"]).write_bytes(outs[min(pick, len(outs) - 1)].read_bytes()) + return outs + + +def generate(name, spec): + kind = spec.get("kind", "image") + fn = {"image": gen_image, "tiles": gen_tiles}[kind] + t0 = time.time() + outs = fn(name, spec) + print(f"[ok] {name} ({time.time() - t0:.1f}s) -> {', '.join(p.name for p in outs)}", flush=True) + + +def main(argv): + manifest = json.loads(MANIFEST.read_text()) + if "--balance" in argv: + print(request("GET", "/balance")[1]) + return + if "--list" in argv: + for name, spec in manifest.items(): + have = (OUT_DIR / spec["out"]).exists() + print(f"{'x' if have else ' '} {name:20s} {spec['out']}") + return + force = "--force" in argv + names = [a for a in argv if not a.startswith("--")] + unknown = [n for n in names if n not in manifest] + if unknown: + sys.exit(f"unknown asset(s): {unknown}") + todo = { + n: s + for n, s in manifest.items() + if (n in names) or (not names and (force or not (OUT_DIR / s["out"]).exists())) + } + if not todo: + print("all assets present") + return + failures = 0 + with ThreadPoolExecutor(max_workers=4) as pool: + futs = {pool.submit(generate, n, s): n for n, s in todo.items()} + for f, n in futs.items(): + try: + f.result() + except Exception as e: # report every failure, keep going + failures += 1 + print(f"[fail] {n}: {e}", file=sys.stderr, flush=True) + sys.exit(1 if failures else 0) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/tools/scenery_textures.py b/tools/scenery_textures.py new file mode 100755 index 0000000..add77be --- /dev/null +++ b/tools/scenery_textures.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""Paints the high-resolution, hand-painted-style textures used by the 3D +scenery models (trees, bushes, rocks). Output: assets/models/src/*.png. + +These are deliberately *not* pixel art: in HD-2D, environment props are real +3D models with higher-resolution painterly textures, while characters and tiny +props stay as pixel-art billboards. + + python3 tools/scenery_textures.py (needs numpy + Pillow) + +Run tools/blender_scenery.py afterwards to rebuild the .glb models. +""" +import math +import random +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw, ImageFilter + +ROOT = Path(__file__).resolve().parent.parent +OUT = ROOT / "assets" / "models" / "src" + + +def fft_noise(h, w, power=2.0, stretch=(1.0, 1.0), seed=0): + """Tileable fractal noise in [0, 1] (1/f^power spectrum).""" + rng = np.random.default_rng(seed) + fy = np.fft.fftfreq(h)[:, None] * stretch[0] + fx = np.fft.fftfreq(w)[None, :] * stretch[1] + f = np.sqrt(fx * fx + fy * fy) + f[0, 0] = 1.0 + spectrum = (rng.normal(size=(h, w)) + 1j * rng.normal(size=(h, w))) / f**power + spectrum[0, 0] = 0 + n = np.real(np.fft.ifft2(spectrum)) + n -= n.min() + return n / n.max() + + +def lerp_colors(t, stops): + """Maps t in [0,1] (array) through a list of (pos, (r,g,b)) stops.""" + t = np.clip(t, 0.0, 1.0) + out = np.zeros(t.shape + (3,)) + pos = [p for p, _ in stops] + for c in range(3): + out[..., c] = np.interp(t, pos, [col[c] for _, col in stops]) + return out + + +def leaf_polygon(cx, cy, angle, length, width, steps=10): + pts_l, pts_r = [], [] + ca, sa = math.cos(angle), math.sin(angle) + for i in range(steps + 1): + t = i / steps + x = t * length + y = width * math.sin(math.pi * t) ** 0.75 * (1.0 - 0.25 * t) + for side, pts in ((1, pts_l), (-1, pts_r)): + px, py = x, side * y + pts.append((cx + px * ca - py * sa, cy + px * sa + py * ca)) + return pts_l, pts_r + + +def shade(col, k): + return tuple(int(max(0, min(255, c * k))) for c in col) + + +def draw_leaf(draw, cx, cy, angle, length, width, base): + left, right = leaf_polygon(cx, cy, angle, length, width) + tip = left[-1] + # Two halves with different brightness read as a folded leaf. + draw.polygon(left + right[::-1], fill=base + (255,)) + draw.polygon(left + [tip, (cx, cy)], fill=shade(base, 1.18) + (255,)) + draw.line([(cx, cy), tip], fill=shade(base, 0.72) + (255,), width=max(1, int(width * 0.12))) + + +LEAF_PALETTE = [(34, 82, 30), (52, 112, 34), (78, 146, 40), (112, 176, 52), (150, 204, 74)] + + +def leaf_cluster(size=1024, seed=1, count=320, palette=LEAF_PALETTE): + """A round clump of overlapping leaves on transparency (canopy card).""" + rng = random.Random(seed) + S = size * 2 + img = Image.new("RGBA", (S, S), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + c = S / 2 + leaves = [] + for _ in range(count): + r = (rng.random() ** 0.6) * S * 0.40 + a = rng.random() * math.tau + leaves.append((r, a)) + leaves.sort(key=lambda la: -la[0] + rng.random() * S * 0.1) # outer first, centre on top + for r, a in leaves: + x, y = c + r * math.cos(a), c + r * math.sin(a) + ang = a + rng.uniform(-0.9, 0.9) if r > S * 0.05 else rng.random() * math.tau + length = S * rng.uniform(0.05, 0.085) + depth = 1.0 - r / (S * 0.42) # centre leaves are on top and lighter + idx = min(len(palette) - 1, max(0, int((depth * 0.7 + rng.random() * 0.5) * len(palette)))) + draw_leaf(d, x, y, ang, length, length * 0.42, palette[idx]) + img = img.resize((size, size), Image.LANCZOS) + a = np.array(img) + a[..., 3] = np.where(a[..., 3] > 110, 255, 0) # hard cutout + return Image.fromarray(a) + + +def leaf_dense(size=1024, seed=2, palette=LEAF_PALETTE): + """Opaque tileable leaf mass for canopy cores (fills gaps between cards).""" + rng = random.Random(seed) + S = size * 2 + img = Image.new("RGBA", (S, S), shade(palette[0], 0.8) + (255,)) + d = ImageDraw.Draw(img) + for _ in range(900): + x, y = rng.random() * S, rng.random() * S + ang = rng.random() * math.tau + length = S * rng.uniform(0.04, 0.065) + base = palette[min(len(palette) - 1, int(rng.random() ** 1.4 * len(palette)))] + for ox in (-S, 0, S): # wrap so the texture tiles + for oy in (-S, 0, S): + if -length < x + ox < S + length and -length < y + oy < S + length: + draw_leaf(d, x + ox, y + oy, ang, length, length * 0.42, base) + return img.resize((size, size), Image.LANCZOS) + + +NEEDLE_PALETTE = [(24, 70, 52), (36, 94, 66), (54, 122, 80), (84, 152, 96)] + + +def pine_frond(w=1024, h=512, seed=3): + """A drooping pine branch: stem along +u from the left edge, needles on both sides.""" + rng = random.Random(seed) + W, H = w * 2, h * 2 + img = Image.new("RGBA", (W, H), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + cy = H * 0.5 + + def stem_y(x): + return cy + (x / W) ** 2 * H * 0.08 + + for layer in range(3): + for i in range(520): + t = rng.random() + x = t * W * 0.96 + y = stem_y(x) + env = math.sin(math.pi * min(1.0, t * 1.1)) ** 0.6 * (1.0 - 0.45 * t) # leaf-shaped outline + side = rng.choice((-1, 1)) + length = H * 0.5 * env * rng.uniform(0.6, 1.0) + ang = side * rng.uniform(0.55, 1.05) - 0.25 * side * t + ex, ey = x + math.cos(ang) * length * 0.55, y + math.sin(ang) * length + col = NEEDLE_PALETTE[min(3, layer + int(rng.random() * 2))] + d.line([(x, y), (ex, ey)], fill=col + (255,), width=int(rng.uniform(6, 10))) + d.line([(x, y), (ex, ey)], fill=shade(col, 1.25) + (255,), width=2) + d.line([(x0, stem_y(x0)) for x0 in range(0, int(W * 0.97), 8)], fill=(58, 70, 44, 255), width=4) + img = img.resize((w, h), Image.LANCZOS) + a = np.array(img) + a[..., 3] = np.where(a[..., 3] > 110, 255, 0) + return Image.fromarray(a) + + +def bark(w=512, h=1024, seed=4): + n = fft_noise(h, w, power=1.25, stretch=(6.0, 1.0), seed=seed) # long vertical fibres + fine = fft_noise(h, w, power=0.9, stretch=(3.0, 1.0), seed=seed + 1) + v = n * 0.75 + fine * 0.25 + col = lerp_colors(v, [(0.0, (34, 22, 16)), (0.35, (66, 44, 30)), (0.6, (104, 72, 48)), (1.0, (150, 112, 78))]) + crev = np.clip((0.33 - n) * 6.0, 0, 1)[..., None] + col = col * (1 - 0.65 * crev) + return Image.fromarray(col.astype(np.uint8), "RGB") + + +def rock(size=1024, seed=5): + base = fft_noise(size, size, power=1.8, seed=seed) + fine = fft_noise(size, size, power=0.9, seed=seed + 1) + ridge = 1.0 - np.abs(fft_noise(size, size, power=1.5, seed=seed + 2) * 2 - 1) + v = np.clip((base * 0.6 + fine * 0.4 - 0.5) * 1.6 + 0.5, 0, 1) + col = lerp_colors(v, [(0.0, (52, 52, 56)), (0.4, (104, 102, 98)), (0.7, (148, 144, 134)), (1.0, (196, 190, 176))]) + cracks = np.clip((ridge - 0.965) * 18.0, 0, 1)[..., None] + col = col * (1 - 0.55 * cracks) + lichen = np.clip((fft_noise(size, size, power=1.4, seed=seed + 3) - 0.72) * 8.0, 0, 1)[..., None] + col = col * (1 - lichen * 0.35) + np.array([150, 160, 110]) * lichen * 0.35 + return Image.fromarray(col.astype(np.uint8), "RGB") + + +def voronoi(size, count, seed): + """Tileable Voronoi: (F1, F2, cell id) per pixel.""" + rng = np.random.default_rng(seed) + pts = rng.random((count, 2)) * size + yy, xx = np.mgrid[0:size, 0:size].astype(np.float32) + f1 = np.full((size, size), 1e9, np.float32) + f2 = np.full((size, size), 1e9, np.float32) + ids = np.zeros((size, size), np.int32) + for i, (px, py) in enumerate(pts): + for ox in (-size, 0, size): + for oy in (-size, 0, size): + d = np.hypot(xx - (px + ox), yy - (py + oy)) + closer = d < f1 + f2 = np.where(closer, f1, np.minimum(f2, d)) + ids = np.where(closer, i, ids) + f1 = np.where(closer, d, f1) + return f1, f2, ids + + +def cobble(size=1024, seed=8, count=70): + """Large worn flagstones with dark mortar (bridge deck).""" + f1, f2, ids = voronoi(size, count, seed) + rng = np.random.default_rng(seed + 1) + tone = rng.uniform(0.75, 1.15, count)[ids] + edge = f2 - f1 + cell = size / math.sqrt(count) + dome = np.clip(1.0 - f1 / (cell * 0.75), 0, 1) ** 0.5 # stones bulge slightly + grain = fft_noise(size, size, power=1.1, seed=seed + 2) + v = np.clip((0.45 + 0.35 * dome + 0.35 * (grain - 0.5)) * tone, 0, 1) + col = lerp_colors(v, [(0.0, (58, 56, 54)), (0.45, (112, 108, 100)), (0.75, (150, 144, 132)), (1.0, (186, 178, 162))]) + mortar = np.clip(1.0 - edge / 9.0, 0, 1)[..., None] + col = col * (1 - mortar) + np.array([40, 38, 34]) * mortar + bevel = np.clip(1.0 - (edge - 9.0) / 14.0, 0, 1)[..., None] * (1 - mortar) # darker stone rims + col = col * (1 - 0.3 * bevel) + moss_mask = np.clip((fft_noise(size, size, power=1.6, seed=seed + 3) - 0.68) * 6.0, 0, 1)[..., None] * mortar + col = col * (1 - moss_mask) + np.array([70, 104, 40]) * moss_mask + return Image.fromarray(col.astype(np.uint8), "RGB") + + +def stone_blocks(size=1024, seed=9, rows=4, cols=3): + """Staggered dressed stone blocks with mortar (bridge walls, pillars).""" + rng = np.random.default_rng(seed) + yy, xx = np.mgrid[0:size, 0:size].astype(np.float32) + row_h = size / rows + r = (yy // row_h).astype(int) + offset = (r % 2) * 0.5 * (size / cols) + rng.uniform(-20, 20, rows)[r] + bw = size / cols + xs = (xx + offset) % size + c = (xs // bw).astype(int) + lx, ly = xs - c * bw, yy - r * row_h + edge = np.minimum(np.minimum(lx, bw - lx), np.minimum(ly, row_h - ly)) + tone = rng.uniform(0.8, 1.15, (rows, cols + 1))[r, c] + grain = fft_noise(size, size, power=1.2, seed=seed + 1) + v = np.clip((0.55 + 0.4 * (grain - 0.5)) * tone, 0, 1) + col = lerp_colors(v, [(0.0, (60, 58, 56)), (0.5, (126, 120, 110)), (1.0, (182, 174, 158))]) + chip = np.clip(1.0 - edge / 16.0, 0, 1)[..., None] + col = col * (1 - 0.35 * chip) + mortar = (edge < 5.0)[..., None] + col = np.where(mortar, np.array([46, 44, 40]), col) + return Image.fromarray(col.astype(np.uint8), "RGB") + + +def iron(size=512, seed=10): + v = fft_noise(size, size, power=1.3, seed=seed) + col = lerp_colors(v, [(0.0, (22, 20, 20)), (0.6, (48, 44, 42)), (1.0, (86, 70, 56))]) # rust in highlights + return Image.fromarray(col.astype(np.uint8), "RGB") + + +def coals(size=256, seed=11): + f1, f2, ids = voronoi(size, 40, seed) + edge = np.clip((f2 - f1) / 10.0, 0, 1) + hot = np.random.default_rng(seed).uniform(0.4, 1.0, 40)[ids] * edge + col = lerp_colors(hot, [(0.0, (20, 8, 4)), (0.5, (160, 50, 10)), (1.0, (255, 170, 60))]) + return Image.fromarray(col.astype(np.uint8), "RGB") + + +def main(): + OUT.mkdir(parents=True, exist_ok=True) + leaf_cluster(seed=1).save(OUT / "leaves_oak.png") + leaf_cluster(seed=7, count=360, palette=[(30, 84, 40), (44, 116, 46), (70, 150, 52), (104, 182, 62), (150, 210, 86)]).save(OUT / "leaves_bush.png") + leaf_dense().save(OUT / "leaves_dense.png") + pine_frond().save(OUT / "pine_frond.png") + bark().save(OUT / "bark.png") + rock().save(OUT / "rock.png") + cobble().save(OUT / "cobble.png") + stone_blocks().save(OUT / "stone_blocks.png") + iron().save(OUT / "iron.png") + coals().save(OUT / "coals.png") + print("wrote", ", ".join(sorted(p.name for p in OUT.glob("*.png")))) + + +if __name__ == "__main__": + main()