Files
Vibe-HD-2D/docs/ASSET_GUIDE.md
Gabrieal Jimmy 16c03afd16 Rain and more
2026-10-02 13:04:49 -05:00

254 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<stem>_<n>.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.