diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..90cc0b5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,50 @@ +# Notes for AI agents + +C++20 / OpenGL 4.5 HD-2D engine (`engine/`) plus a demo (`demo/`). Read +`README.md` for features and the frame pipeline, and **`docs/ASSET_GUIDE.md` +before making any sprite, texture or model**. + +## Build and run + +```sh +cmake -S . -B build -G Ninja && ninja -C build +./build/hd2d_demo # interactive (WASD, R = rain, F1 = panels) +./build/hd2d_demo --screenshot out.png --frames 90 \ + --pos 25,43.6 --dist 10 --yaw 30 --rain 1 --set dof=0 # scripted capture +``` + +There is no test suite; verification is visual. After any rendering, asset or +gameplay change, capture screenshots of the affected area and look at them. +Use a Debug build (`-DCMAKE_BUILD_TYPE=Debug`) to get OpenGL errors printed. + +## Map + +| Path | What | +| --- | --- | +| `engine/include/hd2d/`, `engine/src/` | engine library (`Renderer`, `Terrain`, `BillboardBatch`, `ModelLibrary`/`ModelBatch`, `Water`, `ParticleSystem`, `Weather`, `GroundOcclusion`, `DebugUI`) | +| `assets/shaders/` | GLSL; `common.glsl` holds the shared `Frame` UBO and lighting helpers | +| `demo/` | meadow scene: terrain layout, prop scattering, bridge, fires, player movement | +| `tools/pixellab.py` + `tools/assets.json` | pixel-art generation (PixelLab API, key in `.env`) | +| `tools/scenery_textures.py` | high-res painted textures for models | +| `tools/blender_scenery.py` | procedural 3D models → `assets/models/scenery.glb` | + +## Rules + +- `FrameUBO` in `engine/src/Renderer.cpp` must mirror `Frame` in + `assets/shaders/common.glsl` exactly (std140, vec4/mat4 only); update the + `static_assert` size when adding fields. +- `pixelart.glsl` uses derivatives: include it only in fragment shaders. +- Use GL 4.5 DSA calls (`glCreate*`, `glNamed*`, `glTexture*`) like the rest + of the code. Shaders hot-reload with F5. +- Shaders and assets load from `HD2D_ASSET_DIR` (set by CMake to `assets/`). +- Generated files (`assets/textures/*`, `assets/models/*`) come from scripts in + `tools/`; change the script, not the output. +- PixelLab calls cost credits: check `--balance`, reuse `tile_id`s, and do not + regenerate assets that already exist unless asked. +- The Blender script only touches its `HD2D_Scenery` collection; keep it that + way when running it inside a user's open Blender session. +- New tunables go in `RenderSettings` (or the owning class), get a slider in + `DebugUI.cpp`, and, if useful for captures, an entry in `applySetting` in + `demo/main.cpp`. +- Update `README.md` / `docs/ASSET_GUIDE.md` when behavior or conventions + change. diff --git a/README.md b/README.md index 8d2c377..13827ce 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ high-resolution 3D effects. | 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 | +| 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 | @@ -30,7 +30,7 @@ high-resolution 3D effects. | 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 | +| Fire | Brazier fires (`ParticleSystem::addFire`): a compact, bowl-filling mass of nearly opaque, noise-eroded flame licks shaded per pixel from a hot yellow core through orange to red tips (drawn oldest-first so the core sits on top), turbulent embers, lit smoke puffs, a glow halo, and a flickering point light that also scatters into the fog | | Weather | `Weather` blends the clear look into a storm preset (weak grey sun, overcast sky, thick low fog, gusty wind): procedural world-space rain streaks (up to 18k, one draw call), ground splashes, raindrop ripple rings on the water, wet darker glossy surfaces, ambient fireflies fade out | 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), @@ -94,6 +94,8 @@ 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`) @@ -168,6 +170,10 @@ shore field, particles, weather, player movement and collision. ## Assets +The full guide to making sprites, terrain tiles, high-res textures and 3D +models (scale, conventions, PixelLab manifest fields, Blender helpers, how to +check results) is in [`docs/ASSET_GUIDE.md`](docs/ASSET_GUIDE.md). Summary: + ### 3D scenery (Blender) Trees, bushes, rocks and the stone bridge (with braziers) are generated diff --git a/assets/models/scenery.glb b/assets/models/scenery.glb index 3592a61..af132e8 100644 Binary files a/assets/models/scenery.glb and b/assets/models/scenery.glb differ diff --git a/assets/shaders/mesh.frag b/assets/shaders/mesh.frag index 060a46c..ea8b7a3 100644 --- a/assets/shaders/mesh.frag +++ b/assets/shaders/mesh.frag @@ -35,7 +35,10 @@ void main() { 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); + // Only geometry above the sprite's head fades: low walls and trunk bases + // stay solid, the player is still readable over them. + float aboveHead = smoothstep(uBlob.y + 0.9, uBlob.y + 1.4, v.worldPos.y); + float fade = (1.0 - smoothstep(0.07, 0.15, length(d))) * step(fragDepth, pc.w - 1.0) * aboveHead; if (ign(gl_FragCoord.xy) < fade * 0.85) discard; } diff --git a/assets/shaders/particles.frag b/assets/shaders/particles.frag index 763981a..7673982 100644 --- a/assets/shaders/particles.frag +++ b/assets/shaders/particles.frag @@ -20,17 +20,25 @@ void main() { 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); + if (vKind == 3) { + // Flame lick: a soft-topped blob with a crisp, noise-eroded silhouette, + // shaded from a hot yellow center to orange and a red rim; it reddens + // and erodes as it rises. Nearly opaque (HD-2D fires read as solid, + // painterly shapes), emissive HDR so the core blooms. + float taper = 1.0 + 0.45 * (p.y * 0.5 + 0.5); + float d = length(vec2(p.x * taper, p.y * 0.8 + 0.1)); + vec2 q = vec2(p.x * 1.8, p.y * 1.5 - uTime * 2.6) + seed * 37.0; + float n = vnoise(q) * 0.7 + vnoise(q * 2.4 + 11.0) * 0.3; + float e = d + (n - 0.5) * (0.35 + 0.7 * life); + float body = 1.0 - smoothstep(0.62, 0.76, e); + if (body <= 0.0) discard; + // Young licks stay hot right to their rim so the mass merges into one + // bright body; only older, higher licks get the red edge. + float heat = clamp((1.0 - e / 0.72) * 1.35 + 0.25 * (1.0 - life), 0.0, 1.0) * (1.0 - 0.65 * life); + vec3 red = vec3(0.8, 0.1, 0.02), orange = vec3(1.8, 0.5, 0.06), yellow = vec3(2.3, 1.4, 0.32); + vec3 c = heat < 0.5 ? mix(red, orange, heat * 2.0) : mix(orange, yellow, heat * 2.0 - 1.0); + float a = body * vColor.a; + fragColor = vec4(c * vColor.rgb * a, a * 0.92); return; } if (vKind == 4) { // ember diff --git a/assets/shaders/particles.vert b/assets/shaders/particles.vert index 629b8fd..83cf6ee 100644 --- a/assets/shaders/particles.vert +++ b/assets/shaders/particles.vert @@ -24,7 +24,7 @@ void main() { 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); + offset *= vec2(1.0, 1.3); } if (kind == 5) { // smoke puffs rotate slowly extent = 1.0; diff --git a/demo/DemoScene.cpp b/demo/DemoScene.cpp index eb4c914..7881686 100644 --- a/demo/DemoScene.cpp +++ b/demo/DemoScene.cpp @@ -94,6 +94,7 @@ bool DemoScene::load(const std::string& assetDir) { 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_.occluderFade = false; // low; the camera sees over it bridge_.upload(); // Fires burn in the braziers on the bridge's entrance pillars. for (const auto& [name, local] : bridge_.model->sockets) @@ -104,6 +105,8 @@ bool DemoScene::load(const std::string& assetDir) { for (ModelBatch& b : pines_) b.groundOcclusion = 1.0f; for (ModelBatch& b : bushes_) b.groundOcclusion = 0.8f; for (ModelBatch& b : rocks_) b.groundOcclusion = 0.7f; + for (ModelBatch& b : rocks_) b.occluderFade = false; + for (ModelBatch& b : bushes_) b.occluderFade = false; tufts_.inReflections = false; // tiny, never visible in the pond flowers_.inReflections = false; diff --git a/docs/ASSET_GUIDE.md b/docs/ASSET_GUIDE.md new file mode 100644 index 0000000..19bf121 --- /dev/null +++ b/docs/ASSET_GUIDE.md @@ -0,0 +1,253 @@ +# Asset guide: pixel art, textures and 3D models + +How to make art for this engine, written for humans and AI agents alike. +Everything here is reproducible from scripts in `tools/`; never hand-edit a +generated file without also changing the script that produces it. + +## 1. What is pixel art and what is 3D + +HD-2D mixes two kinds of art. Pick the right one before making anything. + +| Thing | Kind | Where it lives | How it is drawn | +| --- | --- | --- | --- | +| Characters, NPCs, monsters | Pixel-art **sprite** | `assets/sprites/` | `BillboardBatch` (camera-facing card) | +| Small props: flowers, grass tufts, tall grass, signs, pickups | Pixel-art **sprite** | `assets/textures/` | `BillboardBatch` | +| Ground, paths, cliff faces | Pixel-art **tiles** (32 × 32) | `assets/textures/ground_*` | `Terrain` texture array | +| Trees, bushes, rocks, bridges, buildings, fences, braziers | **3D model** with higher-res painted textures | `assets/models/scenery.glb` | `ModelBatch` (instanced) | +| Fire, rain, fog, light, water, sparkles | **Effect**, no asset | shaders / `ParticleSystem` / `Weather` | engine code | + +Rule of thumb: if it is smaller than the player or is a character, it is a +sprite. If it is scenery the player walks around, it is a 3D model. + +## 2. Units and scale + +- 1 world unit = 1 terrain tile = **32 pixel-art pixels**. Keep all pixel art + at this density so pixels line up (`BillboardBatch::sizeForDensity(32)`). +- Art that was upscaled (e.g. the 256 px test sprite is 64 px art × 4) is drawn + at `32 × factor` px per unit (`sizeForDensity(128)`). +- The engine is **Y-up**; Blender is **Z-up**. The glTF exporter converts, so + model in Blender with +Z up and the base of the object at Z = 0. +- Reference sizes (world units): player sprite ≈ 1.5 tall; oak ≈ 5.5 tall, + canopy ≈ 5 wide; pine ≈ 6 tall; bush ≈ 0.9 tall, 1.6 wide; rock ≈ 0.6 tall; + bridge deck 17 long, 2.6 wide; cliff step 0.5 high per terrain level. +- High-res model textures: aim for roughly 256–512 texels per world unit + (1024² leaf cards, 512 × 1024 bark, 1024² stone). + +## 3. Pixel art (PixelLab) + +All pixel art comes from the [PixelLab API](https://api.pixellab.ai/v2/llms.txt) +through `tools/pixellab.py`, driven by the manifest `tools/assets.json`. The +key is `PIXELLAB_API_KEY` in `.env` (never commit it; `.env` is gitignored). + +```sh +python3 tools/pixellab.py --list # x = file exists +python3 tools/pixellab.py # generate everything missing +python3 tools/pixellab.py flowers # regenerate one entry (costs credits) +python3 tools/pixellab.py --balance # check credits before big batches +``` + +### Manifest entries + +Every key except `kind`, `out`, `pick` and `tile_id` is sent to the API as-is, +so any documented endpoint parameter can be used. + +**Sprite** (`"kind": "image"`, default) → `POST /create-image-pixflux`, synchronous: + +```json +"lantern": { + "out": "lantern.png", + "description": "small iron street lantern on a short post, warm glass, game sprite", + "image_size": {"width": 32, "height": 64}, + "no_background": true, + "view": "side", + "outline": "selective outline", + "shading": "medium shading", + "detail": "highly detailed", + "seed": 40 +} +``` + +- Max 400 × 400. Use sizes that are multiples of 16 and match the 32 px/unit + density (a 1-unit-tall prop is 32 px tall). +- Always `"no_background": true`; sprites must be transparent. +- `"view": "side"` reads best for billboards (the camera looks from the side + and slightly above). Enums: outline `single color black outline | single + color outline | selective outline | lineless`; shading `flat | basic | medium + | detailed | highly detailed shading`; detail `low detail | medium detail | + highly detailed`. +- Fix the `seed` so regenerating is reproducible. + +**Seamless tiles** (`"kind": "tiles"`) → `POST /create-tiles-pro`, async: + +```json +"ground_snow": { + "kind": "tiles", + "out": "ground_snow.png", + "pick": 0, + "description": "1). fresh snow, soft blue shadows 2). snow with footprints 3). snow over grass 4). icy snow", + "tile_type": "square_topdown", + "tile_size": 32, + "tile_view": "top-down", + "outline_mode": "segmentation", + "seed": 14 +} +``` + +- Numbered descriptions give one variation group each; the API returns ~16 + tiles saved as `_.png` (4 per numbered item, in order). +- After the first run the script prints a `tile_id`; **add it to the entry** + so later runs re-download for free instead of paying again. +- The storage host rejects default user agents; the script already sends one. +- Use only `square_topdown`, 32 px, `top-down` for terrain. + +### Sprite rules the engine relies on + +- **Feet on the lowest opaque row.** Billboards are planted by the lowest + pixel with alpha ≥ 128 (`Texture::opaqueBounds`), so empty space below the + feet is fine but stray pixels below them make the sprite float. +- **No baked ground shadows.** The engine casts real shadows and draws a + contact blob under the player. PixelLab sometimes paints a grey ellipse under + objects; regenerate or erase it (alpha 0) before use. +- Hard alpha (0 or 255) is ideal; soft edges are thresholded at 0.5. +- Sprites face **left** by default; the demo mirrors them when walking right. + +### Using pixel art in the engine + +- Sprite: `BillboardBatch b; b.loadTexture(path);` then push + `BillboardInstance{pos, sway, size, flip, tint}` and `b.upload()`. + `sway` 0 = rigid, 1 = grass-like wind and player push. +- Terrain layer: add the file stem to `kLayers` in `demo/DemoScene.cpp` (all + layers must be the same size) and reference its index from a + `TerrainMaterial`. + +### Characters and animation (not wired up yet) + +PixelLab can make consistent characters with 4/8 directions and animations +(`POST /create-character-v3`, `/characters/animations`, then +`GET /characters/{id}/zip` or `/spritesheet`). To support them, add a new +`kind` to `tools/pixellab.py` (submit, poll `GET /background-jobs/{id}` every +5–10 s until `completed`, download) and a sprite-sheet animation path to +`BillboardBatch` (UV rect per frame). Keep the 32 px/unit density. + +## 4. High-resolution textures for 3D models + +`tools/scenery_textures.py` paints them procedurally with numpy + Pillow into +`assets/models/src/`: + +```sh +python3 tools/scenery_textures.py +``` + +| File | Size | Used for | Notes | +| --- | --- | --- | --- | +| `leaves_oak.png`, `leaves_bush.png` | 1024² RGBA | canopy cards | cluster of leaves on transparency, **hard alpha** | +| `leaves_dense.png` | 1024² RGB | canopy cores | opaque, tileable leaf mass that fills gaps | +| `pine_frond.png` | 1024 × 512 RGBA | pine branches | stem along +u from the left edge | +| `bark.png` | 512 × 1024 | trunks | vertical fibres, tiles horizontally | +| `rock.png` | 1024² | rocks | grey; moss comes from vertex color | +| `cobble.png`, `stone_blocks.png` | 1024² | bridge deck / walls | Voronoi flagstones, staggered blocks | +| `iron.png`, `coals.png` | 512² / 256² | braziers | coals are emissive | + +Guidelines: + +- Stylized, saturated, painterly (Dragon Quest III HD-2D / Octopath), not + photographic. Bake no lighting except gentle large-scale variation; the + engine lights everything. +- Tileable textures must wrap (use `fft_noise`, which is periodic, or draw + shapes at ±size offsets like `leaf_dense`). +- Cutout textures: threshold alpha to 0/255 (see `leaf_cluster`); the + transparent pixels' color is bled from neighbours automatically on load. +- Add a new texture: write a function returning a PIL image, save it in + `main()`, then reference the file name from a Blender `material()`. + +## 5. 3D models (Blender) + +`tools/blender_scenery.py` builds every model procedurally and exports +**one** `assets/models/scenery.glb` (textures embedded once, one root node per +model). Run it either way: + +```sh +blender -b -P tools/blender_scenery.py # headless +``` + +```python +# inside a running Blender, e.g. via the Blender MCP `execute_blender_code` tool +import os; os.environ["HD2D_REPO"] = "/path/to/repo" +p = "/path/to/repo/tools/blender_scenery.py" +exec(compile(open(p).read(), p, "exec"), {"__name__": "__main__", "__file__": p}) +``` + +It only touches its own `HD2D_Scenery` collection and lays models out side by +side along X for inspection (take a viewport screenshot to check). Re-run +`scenery_textures.py` first if textures changed (and `img.reload()` the images +in a live session). + +### Building blocks + +- `MeshBuilder.face(points, uvs, colors, normals, material_index)` adds a + polygon with per-corner data; `build(name, materials)` makes the object. +- Helpers: `tube` (tapered trunks/branches with bark UVs), `ico` (icosphere), + `clump` (leaf core + alpha-tested leaf cards), `box`, `quad_face` (world + projected UVs), `lathe` (surfaces of revolution: bowls, posts, pots). +- `material(name, image, alpha=False, emission=0.0)` creates the Principled + material the exporter understands (alpha → `MASK`, emission → + `emissiveTexture` + strength). + +### Adding a model + +1. Write `make_thing(name, seed)` returning `mb.build(...)`. +2. Add `("thing", make_thing, seed)` to `MODELS`. +3. Run the script, then load it in the demo: + `ModelBatch b(library.get("thing"))`, push `ModelInstance{pos, yaw, scale, tint}`, `b.upload()`, add to `scene.models`. + +### Conventions the engine depends on + +- **Origin at the ground contact point**, +Z up, base at Z = 0 (slightly below + is fine to avoid gaps on slopes). Long objects run along +X. +- **Vertex color `COLOR_0`**: rgb multiplies the texture (use it for baked + ambient occlusion and tint, e.g. darker at the base, moss on top); alpha is + the **wind weight** (0 rigid, 1 fully swaying; scales with height). +- Materials named **`foliage*`** are alpha-tested, double-sided, get wrap + lighting, sun translucency and soft self-shadowing. Everything else is + single-sided and back-face culled. +- **Winding must be CCW seen from outside.** Single-sided faces are invisible + from behind, so a wrongly wound or misplaced face shows up from one side + only. Check models from several angles, and never put a face across an + opening (the first bridge had an end wall across its entrance that was only + visible from outside). +- Foliage: set custom normals pointing away from the canopy center (`clump` + does this) for soft, rounded shading instead of flat cards. +- **Sockets**: an empty parented to the model becomes `Model::sockets["name"]` + (model space). Use them for attach points (the bridge exports `fire_0`, + `fire_1` in its braziers; the demo spawns fires there with + `ModelInstance::toWorld`). +- Budget: a few thousand faces per model is fine; instancing handles counts. + +### Per-batch options (`ModelBatch`) + +| Field | Use | +| --- | --- | +| `castShadows`, `inReflections` | turn off for tiny props | +| `windScale` | multiplies the vertex wind weight | +| `groundOcclusion` | strength of the baked contact shadow under each instance (trees 1.0, bushes 0.8, rocks 0.7); rebuild `GroundOcclusion` after moving instances | +| `occluderFade` | dither away where it hides the player (tall trees: on; low props like bridges, rocks, bushes: off) | + +Collision is gameplay code, not part of the asset: the demo keeps a list of +circular obstacles and special-cases the bridge deck (its numbers mirror +`BRIDGE_*` in the Blender script; keep them in sync). + +## 6. Checking your work + +Always look at the result; don't assume it looks right. + +```sh +cmake -S . -B build -G Ninja && ninja -C build +./build/hd2d_demo --screenshot out.png --frames 90 --pos X,Z --dist 8 --yaw 30 --set dof=0 +./build/hd2d_demo --screenshot rain.png --frames 90 --pos X,Z --rain 1 +``` + +- `--pos` puts the player (and camera target) at world X,Z; `--yaw` and + `--dist` orbit the camera; `--set dof=0` removes blur for inspection. +- Check every new model from at least two opposite yaws, in rain (wet look, + wind) and near water (reflection). +- Check sprites at default zoom next to the player for scale and pixel size. diff --git a/docs/rain.png b/docs/rain.png index c04407c..56607a4 100644 Binary files a/docs/rain.png and b/docs/rain.png differ diff --git a/docs/screenshot.png b/docs/screenshot.png index 33004de..102508d 100644 Binary files a/docs/screenshot.png and b/docs/screenshot.png differ diff --git a/engine/include/hd2d/Model.h b/engine/include/hd2d/Model.h index 4704866..c50bfaf 100644 --- a/engine/include/hd2d/Model.h +++ b/engine/include/hd2d/Model.h @@ -95,6 +95,9 @@ public: // 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; + // Dither this model away where it hides the player (tall scenery like + // trees). Turn off for low props the camera sees over (bridges, rocks). + bool occluderFade = true; private: std::vector vaos_; diff --git a/engine/src/Particles.cpp b/engine/src/Particles.cpp index 20d372d..454a080 100644 --- a/engine/src/Particles.cpp +++ b/engine/src/Particles.cpp @@ -8,19 +8,6 @@ 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_); @@ -79,11 +66,14 @@ void ParticleSystem::spawnFire(const Fire& f, int fireIndex, Kind kind) { p.age = 0.0f; float a = u(rng_) * 6.2832f; if (kind == Kind::Flame) { - float r = 0.2f * k * std::sqrt(u(rng_)); + // Chunky licks that fill the bowl and stay low and dense, like the + // painterly brazier fires of HD-2D. + float r = 0.3f * 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_)); + p.vel = glm::vec3(s(rng_) * 0.12f, 0.6f + 0.45f * u(rng_), s(rng_) * 0.12f) * std::sqrt(k); + p.life = 0.45f + 0.35f * u(rng_); + p.size = 0.55f * k * (0.8f + 0.4f * u(rng_)) * (1.0f - 0.35f * r / (0.3f * k + 1e-4f)); // smaller at the rim + p.color = glm::vec3(0.9f + 0.2f * 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); @@ -132,11 +122,11 @@ void ParticleSystem::update(float dt, float time) { spawnFire(f, int(i), kind); } }; - emit(f.flameAccum, 45.0f * f.scale, Kind::Flame); + emit(f.flameAccum, 70.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::vec3 glow = glm::vec3(1.0f, 0.42f, 0.1f) * 0.8f * f.flicker; + gpu_.push_back({glm::vec4(f.pos + glm::vec3(0, 0.3f * f.scale, 0), 1.5f * f.scale * f.flicker), glm::vec4(glow, 1.0f), glm::vec4(float(int(Kind::Glow)), 0, 0, 0)}); } @@ -184,13 +174,15 @@ void ParticleSystem::update(float dt, float time) { 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.vel.y += 0.9f * dt; // buoyancy (kept low: compact, bowl-filling fire) + p.vel.x += (std::sin(t * 9.0f) * 0.9f - (p.pos.x - f.pos.x) * 3.5f) * dt; // flicker + taper + p.vel.z += (std::cos(t * 7.3f) * 0.9f - (p.pos.z - f.pos.z) * 3.5f) * 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); + // Intensity only: the yellow -> orange -> red palette is applied + // per pixel in particles.frag from heat (center + youth). + color = p.color * f.flicker; + alpha = std::min(1.0f, lifeT / 0.06f) * std::pow(1.0f - lifeT, 0.6f); + size = p.size * (1.0f - 0.55f * lifeT) * (0.7f + 0.3f * std::min(1.0f, lifeT / 0.12f)); break; } case Kind::Ember: { @@ -222,6 +214,13 @@ void ParticleSystem::update(float dt, float time) { ++i; } + // Flames are alpha-blended (not additive), so draw each fire's oldest, + // reddest tongues first and the young bright core on top. + auto firstFlame = std::stable_partition(gpu_.begin(), gpu_.end(), [](const GpuParticle& g) { + return int(g.params.x) != int(Kind::Flame); + }); + std::sort(firstFlame, gpu_.end(), [](const GpuParticle& a, const GpuParticle& b) { return a.params.y > b.params.y; }); + if (!vao_) { glCreateVertexArrays(1, &vao_); glCreateBuffers(1, &vbo_); diff --git a/engine/src/Renderer.cpp b/engine/src/Renderer.cpp index 27d9187..19d9b55 100644 --- a/engine/src/Renderer.cpp +++ b/engine/src/Renderer.cpp @@ -185,12 +185,13 @@ struct Renderer::Impl { 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); + const bool fadeView = pass == 0 && !reflectionPass && settings->occluderFade; 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); + if (pass == 0) s.set("uOccluderFade", fadeView && b->occluderFade ? 1 : 0); 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); diff --git a/engine/src/Weather.cpp b/engine/src/Weather.cpp index 80015c9..42976ea 100644 --- a/engine/src/Weather.cpp +++ b/engine/src/Weather.cpp @@ -25,7 +25,7 @@ Weather::Weather() { storm.windStrength = 0.2f; storm.windGustSpeed = 3.0f; storm.bloomStrength = 0.06f; - storm.saturation = 0.82f; + storm.saturation = 0.92f; } WeatherLook Weather::capture(const Scene& s, const RenderSettings& r) { diff --git a/tools/blender_scenery.py b/tools/blender_scenery.py index c17deef..fa6aa2d 100644 --- a/tools/blender_scenery.py +++ b/tools/blender_scenery.py @@ -431,10 +431,12 @@ def make_bridge(name, seed): # 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 + for s in (-1, 1): # end faces: below the deck, plus the parapet ends (the entrance stays open) 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) + d = deck_z(x) + for y0, y1, z0, z1 in ((-yo, yo, -0.6, d), (-yo, -yi, d, d + WALL_H), (yi, yo, d, d + WALL_H)): + pts = [V(x, y0, z0), V(x, y1, z0), V(x, y1, z1), V(x, y0, z1)] + 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: