# 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.


## 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.