Files
Vibe-HD-2D/README.md
Gabrieal Jimmy 83a1f16806 Sprite sheets, atlases, game UI, time of day, weather presets, headless captures
- BillboardInstance: per-instance uv sub-rect, feet anchor, turn, dithered alpha, flash;
  BillboardBatch::sharedTexture; Texture createEmpty/update/cellUV/cellAnchor/nearest
- SpriteAtlas: fixed-cell runtime atlas with gutters
- hd2d::ui: Octopath-style panels, fonts (Cinzel, EB Garamond), dialogue box with
  typewriter + portraits + choices, menus, nameplates, toasts, ImGui theme
- DayCycle (dawn/day/dusk/night keyframes, sun + moon arcs), Weather overcast/mist/
  lightning, night-aware presets, stars and moon in the sky
- Hidden-window offscreen captures (Window visible=false, Renderer output FBO,
  OffscreenTarget); --screenshot never opens a window
- Camera::worldToScreen; CMake usable via add_subdirectory (HD2D_BUILD_DEMO,
  HD2D_ENGINE_ASSET_DIR)
2026-10-02 15:05:23 -05:00

22 KiB
Raw Blame History

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 heavy rain game UI

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 (or a per-frame feet row); per instance: sprite-sheet / atlas sub-rect (SpriteAtlas, Texture::cellUV), flip, paper-turn yaw, dithered alpha fade and hit flash
3D scenery with painted textures Trees, bushes, rocks and the stone bridge are instanced glTF models (ModelLibrary, ModelBatch) built in Blender: leaf-card canopies with alpha-tested 1K leaf textures, rounded custom normals for soft stylized shading, sun translucency, wind sway from per-vertex weights, emissive materials (glowing coals)
Grounded objects Two kinds of ambient occlusion: half-res SSAO (depth-reconstructed normals, depth-aware blur, multiplied into the opaque scene before water) and a baked ground contact occlusion map (GroundOcclusion) that puts a dark core at every trunk / bush / rock base plus a soft canopy shade, applied to terrain, sprites and models near the ground
Nothing hides the player Tall scenery (tree canopies) between the camera and the player is dithered away around the sprite, only above the sprite's head; low props opt out per batch (ModelBatch::occluderFade = false, used for the bridge, rocks and bushes)
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): a compact, bowl-filling mass of nearly opaque, noise-eroded flame licks shaded per pixel from a hot yellow core through orange to red tips (drawn oldest-first so the core sits on top), turbulent embers, lit smoke puffs, a glow halo, and a flickering point light that also scatters into the fog
Weather Weather blends the clear look toward overcast (grey sky, weak sun, soft shadows), mist (thick low fog) and storm presets, each with its own 0..1 target and smooth transition: procedural world-space rain streaks (up to 18k, one draw call), ground splashes, raindrop ripple rings on the water, wet darker glossy surfaces, optional lightning flashes, ambient fireflies fade out
Time of day DayCycle turns an hour into a complete look from tweakable keyframes (night, dawn, morning, noon, afternoon, dusk): sun arc by day, a dim cool moon as the shadow-casting light at night, deep blue nights with a twinkling star field and moon disc (also in water reflections), orange / purple dusks, brighter fireflies after dark; weather presets darken with it
Game UI hd2d::ui (Octopath / Triangle Strategy look): translucent navy panels with a gold double border and corner gems, high-res serif text (Cinzel, EB Garamond) with a soft shadow, a typewriter dialogue box with gold name plate, portrait and choices, menus, name plates, toasts, progress bars and a matching Dear ImGui theme

References: Unreal Engine's Octopath Traveler spotlight, the Octopath Traveler II interview, the Unreal Fest Europe 2019 talk "The Fusion of Nostalgia and Novelty", and The Verge on HD-2D's evolution.

Building

Requirements: CMake ≥ 3.21, a C++20 compiler, OpenGL 4.5, libepoxy. GLFW, GLM, stb and cgltf are used from the system when found and fetched otherwise; Dear ImGui is fetched unless a parent project provides imgui.

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

To use the engine from another CMake project:

add_subdirectory(path/to/Vibe-HD-2D hd2d)   # HD2D_BUILD_DEMO defaults OFF here
target_link_libraries(my_game PRIVATE hd2d)
# Engine shaders: renderer.init(std::string(HD2D_ENGINE_ASSET_DIR) + "/shaders", ...)

hd2d publicly defines HD2D_ENGINE_ASSET_DIR (also a CMake cache variable) pointing at the engine's assets/. The engine provides the imgui target (Dear ImGui + GLFW/OpenGL3 backends) unless the parent defines one first; glfw and glm::glm targets from the parent are reused too.

Demo controls

Input Action
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)
O / G Toggle overcast / morning mist
L Toggle lightning (flashes while raining)
[ / ] Time of day −/+ 1 hour (switches the demo to DayCycle)
F5 Hot-reload shaders
F12 Screenshot to screenshots/
T Toggle the game UI showcase (Enter / Space advance the dialogue, Up / Down pick a choice)
Esc Quit

Scriptable captures (useful for look-dev and regression checks):

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

Captures run hidden: --screenshot implies a hidden window (no toplevel is ever created, so nothing pops up or takes focus) rendering into an offscreen target, and all keyboard / mouse input is ignored, so captures can run while you work on the same desktop. --show opts back into a visible capture window (input still ignored). Hidden and visible captures match to within 1/255 on a handful of pixels (driver rounding of the window surface).

Any app can capture headlessly the same way:

window.create(w, h, "capture", false, false, /*visible*/ false);  // swapBuffers no-op, input reports nothing
hd2d::OffscreenTarget out;  out.create(window.width(), window.height());  // RGBA8 + depth/stencil
renderer.setOutputFramebuffer(out.fbo());  // tonemap writes here and leaves it bound for ImGui / ui::*
renderer.render(scene, cam, time);  /* ... UI ... */
out.save("shot.png");  // = hd2d::saveScreenshot(path, w, h, out.fbo())

--rain 0..1, --overcast 0..1 and --fog 0..1 start the capture already at that weather (--lightning enables flashes); --hour H drives the look from DayCycle (without it the scene keeps its hand-tuned look); --pitch deg sets the camera tilt (low values show the sky). --sprite-test adds a labelled row of atlas / sprite-sheet billboards (sub-rect frames, flip, turn, alpha dither, flash) in front of the player. --ui-demo opens the game UI showcase (same as T): --frames 120 captures the dialogue mid-reveal, --frames 420 fully revealed with its choices. --set accepts: volumetrics dof bloom reflections occluderFade ssao groundAO ssaoRadius ssaoIntensity fogDensity fogHeightFalloff godRays shaftStrength shaftCoverage shaftScale cookieStrength exposure bloomStrength tiltShift focusRange.

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/fonts/          OFL fonts for hd2d::ui (Cinzel, Cinzel Decorative, EB Garamond) + licenses
assets/sprites/        character sprites
assets/models/         scenery.glb (trees, bushes, rocks, bridge) + src/ painted textures
demo/                  meadow demo scene + main loop
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
docs/ASSET_GUIDE.md    how to make sprites, tiles, textures and models (for humans and AI agents)
AGENTS.md              build/verify workflow and code rules for AI agents

Frame pipeline (Renderer::render)

flowchart LR
  A[Sun shadow map<br/>terrain, 3D models, sun-facing sprite cards] --> B[Planar reflection<br/>half res, clipped]
  B --> C[HDR scene<br/>sky, terrain, models, billboards]
  C --> C2[SSAO<br/>half res, multiplied in]
  C2 --> D[Water<br/>reads color/depth copies]
  D --> E[Volumetric fog + god rays<br/>half res ray march, fire haze]
  E --> F[Fog composite<br/>depth-aware upsample]
  F --> F2[Rain streaks]
  F2 --> G[Particles<br/>fire, smoke, fireflies]
  G --> H[Depth of field<br/>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

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

// Sprite sheets / runtime atlases: one sub-rect (`uv`) + feet row (`anchor`)
// per instance; `flip`, `turn`, `alpha` (dithered) and `flash` per instance too.
hd2d::SpriteAtlas atlas; atlas.init({64, 64}, 8, 8);
hd2d::BillboardBatch critters; critters.sharedTexture = &atlas.texture();
int slot = atlas.add(image);  // hd2d::Image, e.g. from hd2d::loadImage
hd2d::BillboardInstance c;
c.pos = {12, 0, 9};  c.size = hd2d::BillboardBatch::cellSize({64, 64}, 32.0f);
c.uv = atlas.uv(slot);  c.anchor = atlas.anchor(slot);
critters.instances.push_back(c);  critters.upload();

hd2d::ModelLibrary models; models.load("assets/models/scenery.glb");
hd2d::ModelBatch oaks(models.get("oak_a"));
oaks.instances.push_back({{14, 0, 12}, /*yaw*/ 0.7f, /*scale*/ glm::vec3(1.0f), /*tint*/ 1.0f});
oaks.upload();

hd2d::Scene scene;
scene.terrain = &terrain;
scene.billboards = {&hero, &critters};
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.

Time of day and weather (hd2d/DayCycle.h, hd2d/Weather.h)

Both are meant to be driven externally, e.g. by a game server that sends the in-game hour and the current weather. DayCycle::look(hour) returns a WeatherLook (sun / moon direction, intensity and color, sky colors, ambient, fog, god rays, bloom, exposure / saturation / lift / gain, star + moon visibility, firefly glow); Weather blends it toward its presets and applies the result to the Scene and RenderSettings.

hd2d::DayCycle day;    // default keyframes; tweak day.keys[i].look, sunrise, sunset, moon arc
hd2d::Weather weather;

void onServerState(float hour, const std::string& kind) {  // e.g. "clear", "overcast", "fog", "rain", "storm"
    serverHour = hour;                                       // [0, 24); interpolate between updates if you like
    weather.setRain(kind == "rain" || kind == "storm" ? 1.0f : 0.0f);
    weather.setOvercast(kind == "overcast" ? 1.0f : 0.0f);
    weather.setFog(kind == "fog" ? 1.0f : 0.0f);
    weather.setLightning(kind == "storm");
}
// once after joining: weather.snap();  (skip the transition)

// every frame, before renderer.render():
weather.setClearLook(day.look(serverHour));
weather.update(dt, scene, renderer.settings);
API Notes
DayCycle::look(hour) Smooth (smoothstep) interpolation between keys (wraps at midnight); the light follows a sun arc between sunrise and sunset and a moon arc otherwise, fading out near the horizon so the sun → moon switch is invisible
DayCycle::daylight(hour) / night(hour) 0..1 helpers: sun height (0 at night) and star / moon visibility, e.g. for gameplay (spawn tables, NPC schedules)
Weather::setClearLook(look) Replaces the clear look every frame; without it Weather captures the scene's own look while clear (manual tuning survives)
setRain / setOvercast / setFog (0..1) Targets reached over transitionSeconds; snap() jumps to all of them
setLightning(bool) Random double flashes (sky, sun, fog) while rain > 0.5, about every lightningInterval s
storm, overcastLook, mist Presets authored at full daylight; darkened by the clear look's daylight toward nightBrightness and tinted toward its sky at night, so a midnight storm is dark blue-grey, not a bright grey sky. Sun direction, exposure, grading and fireflies always come from the clear look
ParticleSystem::fireflyGlow Firefly brightness + light; set from WeatherLook::fireflyGlow (the default night keys raise it). ambientFade still fades fireflies and motes in rain / mist

Night look: the "sun" becomes the moon (direction, color, intensity), so shadows keep working; Sky::night turns on the star field and moon disc in sky.frag (hidden by clouds, Sky::clouds / rain). Point lights (fires, fireflies) are absolute HDR, so they read strongly once the ambient drops. With DayCycle driving the look, the sun / sky / fog / grading sliders of the renderer panel are overwritten every frame: tune the keyframes instead (F1 → Weather → Day keyframes).

Game UI (hd2d/UI.h)

hd2d::ui draws an HD-2D JRPG interface with Dear ImGui draw lists (no ImGui windows needed), so it composites over the final frame like the debug panels. All sizes are authored for a 1080 px tall viewport and scaled by ui::scaleFor(viewport).

hd2d::DebugUI imgui;  imgui.init(window);            // creates the ImGui context
namespace ui = hd2d::ui;
ImVec2 vp(window.width(), window.height());
ui::loadFonts(std::string(HD2D_ENGINE_ASSET_DIR) + "/fonts", ui::scaleFor(vp));  // before the first frame
ui::applyTheme();                                     // optional: restyle standard ImGui widgets

ui::DialogueBox box;
box.start({{"Warden", "Welcome, traveller!", portraitTexId}}, {"Yes", "No"});
ui::Toasts toasts;  toasts.push("Field guide updated");

// every frame, between imgui.beginFrame() and imgui.endFrame():
if (confirmPressed && box.advance()) onChoice(box.choice());  // -1 without choices
if (upPressed) box.moveChoice(-1);
box.update(dt);    box.draw(vp);
toasts.update(dt); toasts.draw(vp);
ImDrawList* dl = ImGui::GetForegroundDrawList();
ui::menu(dl, {vp.x * 0.15f, vp.y * 0.4f}, {"Items", "Party", "Quit"}, selected, "Menu", ui::scaleFor(vp));
ui::nameplate(dl, headPx, "Traveller", ui::kGold, ui::scaleFor(vp));  // headPx from Camera::worldToScreen
Piece Notes
loadFonts(dir, pixelScale) Bakes body / small (EB Garamond), name (Cinzel) and title (Cinzel Decorative Bold) at kBodySize… × pixelScale so text is pixel-exact; ImGui's default font stays the default for debug panels. Call outside a frame; call again after a resize to rebake
panel, PanelStyle Navy → near-black gradient, soft drop shadow, gold double border, corner gems + brackets (ornate), tint alpha fades everything
text, textWrapped, textCentered, measure Pixel-snapped text with a 1 px dark shadow (plus a faint halo); null font = current ImGui font
titleText Gold gradient logo with dark halo, warm glow, outline and a gem flourish rule
nameplate Pill label whose pointer tip sits on a screen point
menu, menuAt, menuSize Option list panel with a gold highlight band and bobbing cursor
progressBar Recessed rounded track with a glossy gradient fill
DialogueBox Bottom centre, 70 % width, word-wrapped from the full line so words never jump, UTF-8 typewriter at charsPerSecond with sentencePause / clausePause, optional portrait (ImTextureID + UVs; load pixel art with Texture::load(path, false, true) for crisp nearest filtering), gold speaker plate, blinking gem when revealed, choices above the right end
Toasts Banners stacked at the top centre; slide in, fade out after seconds

Assets

Fonts in assets/fonts/ come from google/fonts under the SIL Open Font License (*-OFL.txt next to each font; files renamed from Family[wght].ttf). Variable fonts are rasterised at their default (Regular) instance.

The full guide to making sprites, terrain tiles, high-res textures and 3D models (scale, conventions, PixelLab manifest fields, Blender helpers, how to check results) is in docs/ASSET_GUIDE.md. Summary:

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:

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. Put your key in .env (see .env.example); assets are declared in tools/assets.json.

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 <name>_<n>.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: animation playback + 4/8-direction facing on top of the sheet/atlas frames, a scene/level file format and editor, more weather (snow, wind-blown leaves), cascaded shadows for large maps, audio, and an entity/component layer for gameplay.