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