12 KiB
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 × factorpx 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
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).
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:
"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: outlinesingle color black outline | single color outline | selective outline | lineless; shadingflat | basic | medium | detailed | highly detailed shading; detaillow detail | medium detail | highly detailed.- Fix the
seedso regenerating is reproducible.
Seamless tiles ("kind": "tiles") → POST /create-tiles-pro, async:
"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-downfor 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 pushBillboardInstance{pos, sway, size, flip, tint}andb.upload().sway0 = rigid, 1 = grass-like wind and player push. - Terrain layer: add the file stem to
kLayersindemo/DemoScene.cpp(all layers must be the same size) and reference its index from aTerrainMaterial.
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/:
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 likeleaf_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 Blendermaterial().
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:
blender -b -P tools/blender_scenery.py # headless
# 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
- Write
make_thing(name, seed)returningmb.build(...). - Add
("thing", make_thing, seed)toMODELS. - Run the script, then load it in the demo:
ModelBatch b(library.get("thing")), pushModelInstance{pos, yaw, scale, tint},b.upload(), add toscene.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 (
clumpdoes 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 exportsfire_0,fire_1in its braziers; the demo spawns fires there withModelInstance::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.
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
--posputs the player (and camera target) at world X,Z;--yawand--distorbit the camera;--set dof=0removes 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.