Rain and more
This commit is contained in:
50
AGENTS.md
Normal file
50
AGENTS.md
Normal file
@@ -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.
|
||||||
10
README.md
10
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 |
|
| 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) |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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),
|
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/pixellab.py pixel-art generator, driven by tools/assets.json
|
||||||
tools/scenery_textures.py paints the high-res scenery textures
|
tools/scenery_textures.py paints the high-res scenery textures
|
||||||
tools/blender_scenery.py builds + exports the 3D scenery in Blender
|
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`)
|
### Frame pipeline (`Renderer::render`)
|
||||||
@@ -168,6 +170,10 @@ shore field, particles, weather, player movement and collision.
|
|||||||
|
|
||||||
## Assets
|
## 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)
|
### 3D scenery (Blender)
|
||||||
|
|
||||||
Trees, bushes, rocks and the stone bridge (with braziers) are generated
|
Trees, bushes, rocks and the stone bridge (with braziers) are generated
|
||||||
|
|||||||
Binary file not shown.
@@ -35,7 +35,10 @@ void main() {
|
|||||||
vec2 ps = pc.xy / pc.w * 0.5 + 0.5;
|
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);
|
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 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;
|
if (ign(gl_FragCoord.xy) < fade * 0.85) discard;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,17 +20,25 @@ void main() {
|
|||||||
fragColor = vec4(vColor.rgb * a, a);
|
fragColor = vec4(vColor.rgb * a, a);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (vKind == 3) { // flame: teardrop eroded by scrolling noise, more as it ages
|
if (vKind == 3) {
|
||||||
float taper = 1.0 + 0.7 * (p.y * 0.5 + 0.5);
|
// Flame lick: a soft-topped blob with a crisp, noise-eroded silhouette,
|
||||||
float d = length(vec2(p.x * taper, p.y * 0.9 + 0.1));
|
// shaded from a hot yellow center to orange and a red rim; it reddens
|
||||||
vec2 q = vec2(p.x * 2.5, p.y * 2.0 - uTime * 3.5) + seed * 37.0;
|
// and erodes as it rises. Nearly opaque (HD-2D fires read as solid,
|
||||||
float n = vnoise(q) * 0.6 + vnoise(q * 2.3 + 11.0) * 0.4;
|
// painterly shapes), emissive HDR so the core blooms.
|
||||||
float shape = 1.0 - smoothstep(0.45, 0.85, d + (n - 0.5) * (0.6 + 0.9 * life));
|
float taper = 1.0 + 0.45 * (p.y * 0.5 + 0.5);
|
||||||
float core = 1.0 - smoothstep(0.0, 0.55, d);
|
float d = length(vec2(p.x * taper, p.y * 0.8 + 0.1));
|
||||||
float a = shape * vColor.a;
|
vec2 q = vec2(p.x * 1.8, p.y * 1.5 - uTime * 2.6) + seed * 37.0;
|
||||||
// Mostly additive, but each tongue also dims what is behind it a little,
|
float n = vnoise(q) * 0.7 + vnoise(q * 2.4 + 11.0) * 0.3;
|
||||||
// so flames stay saturated orange over bright daytime ground.
|
float e = d + (n - 0.5) * (0.35 + 0.7 * life);
|
||||||
fragColor = vec4(vColor.rgb * a * (0.7 + 0.8 * core), a * 0.3);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (vKind == 4) { // ember
|
if (vKind == 4) { // ember
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ void main() {
|
|||||||
if (kind == 2 || kind == 4 || kind == 6) extent = 1.0;
|
if (kind == 2 || kind == 4 || kind == 6) extent = 1.0;
|
||||||
if (kind == 3) { // flame tongues are taller than wide
|
if (kind == 3) { // flame tongues are taller than wide
|
||||||
extent = 1.0;
|
extent = 1.0;
|
||||||
offset *= vec2(1.0, 1.7);
|
offset *= vec2(1.0, 1.3);
|
||||||
}
|
}
|
||||||
if (kind == 5) { // smoke puffs rotate slowly
|
if (kind == 5) { // smoke puffs rotate slowly
|
||||||
extent = 1.0;
|
extent = 1.0;
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ bool DemoScene::load(const std::string& assetDir) {
|
|||||||
bridge_.model = models_.get("bridge");
|
bridge_.model = models_.get("bridge");
|
||||||
if (!bridge_.model) return false;
|
if (!bridge_.model) return false;
|
||||||
bridge_.instances = {{{kBridgeCenter.x, 0.0f, kBridgeCenter.y}, 0.0f, glm::vec3(1.0f), 1.0f}};
|
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();
|
bridge_.upload();
|
||||||
// Fires burn in the braziers on the bridge's entrance pillars.
|
// Fires burn in the braziers on the bridge's entrance pillars.
|
||||||
for (const auto& [name, local] : bridge_.model->sockets)
|
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 : pines_) b.groundOcclusion = 1.0f;
|
||||||
for (ModelBatch& b : bushes_) b.groundOcclusion = 0.8f;
|
for (ModelBatch& b : bushes_) b.groundOcclusion = 0.8f;
|
||||||
for (ModelBatch& b : rocks_) b.groundOcclusion = 0.7f;
|
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
|
tufts_.inReflections = false; // tiny, never visible in the pond
|
||||||
flowers_.inReflections = false;
|
flowers_.inReflections = false;
|
||||||
|
|||||||
253
docs/ASSET_GUIDE.md
Normal file
253
docs/ASSET_GUIDE.md
Normal file
@@ -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 `<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.
|
||||||
BIN
docs/rain.png
BIN
docs/rain.png
Binary file not shown.
|
Before Width: | Height: | Size: 2.9 MiB After Width: | Height: | Size: 3.0 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.0 MiB After Width: | Height: | Size: 3.0 MiB |
@@ -95,6 +95,9 @@ public:
|
|||||||
// Strength of the contact shadow baked into the ground under each instance
|
// Strength of the contact shadow baked into the ground under each instance
|
||||||
// (see GroundOcclusion); 0 disables. Its size follows the model's footprint.
|
// (see GroundOcclusion); 0 disables. Its size follows the model's footprint.
|
||||||
float groundOcclusion = 0.0f;
|
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:
|
private:
|
||||||
std::vector<GLuint> vaos_;
|
std::vector<GLuint> vaos_;
|
||||||
|
|||||||
@@ -8,19 +8,6 @@
|
|||||||
|
|
||||||
namespace hd2d {
|
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() {
|
ParticleSystem::~ParticleSystem() {
|
||||||
if (vbo_) glDeleteBuffers(1, &vbo_);
|
if (vbo_) glDeleteBuffers(1, &vbo_);
|
||||||
if (vao_) glDeleteVertexArrays(1, &vao_);
|
if (vao_) glDeleteVertexArrays(1, &vao_);
|
||||||
@@ -79,11 +66,14 @@ void ParticleSystem::spawnFire(const Fire& f, int fireIndex, Kind kind) {
|
|||||||
p.age = 0.0f;
|
p.age = 0.0f;
|
||||||
float a = u(rng_) * 6.2832f;
|
float a = u(rng_) * 6.2832f;
|
||||||
if (kind == Kind::Flame) {
|
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.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.vel = glm::vec3(s(rng_) * 0.12f, 0.6f + 0.45f * u(rng_), s(rng_) * 0.12f) * std::sqrt(k);
|
||||||
p.life = 0.5f + 0.4f * u(rng_);
|
p.life = 0.45f + 0.35f * u(rng_);
|
||||||
p.size = 0.42f * k * (0.8f + 0.4f * 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) {
|
} else if (kind == Kind::Ember) {
|
||||||
float r = 0.15f * k * u(rng_);
|
float r = 0.15f * k * u(rng_);
|
||||||
p.pos = f.pos + glm::vec3(std::cos(a) * r, 0.1f * k, std::sin(a) * r);
|
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);
|
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.emberAccum, 14.0f * f.scale, Kind::Ember);
|
||||||
emit(f.smokeAccum, 6.0f * f.scale, Kind::Smoke);
|
emit(f.smokeAccum, 6.0f * f.scale, Kind::Smoke);
|
||||||
glm::vec3 glow = glm::vec3(1.0f, 0.45f, 0.13f) * 0.9f * 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.35f * f.scale, 0), 1.1f * f.scale * 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)});
|
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;
|
break;
|
||||||
case Kind::Flame: {
|
case Kind::Flame: {
|
||||||
const Fire& f = fires_[p.fire];
|
const Fire& f = fires_[p.fire];
|
||||||
p.vel.y += 1.6f * dt; // buoyancy
|
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) * 5.0f) * dt; // flicker + taper
|
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) * 5.0f) * dt;
|
p.vel.z += (std::cos(t * 7.3f) * 0.9f - (p.pos.z - f.pos.z) * 3.5f) * dt;
|
||||||
p.pos += p.vel * dt;
|
p.pos += p.vel * dt;
|
||||||
color = flameColor(lifeT) * 0.22f; // tongues overlap a lot; keep the sum orange, not white
|
// Intensity only: the yellow -> orange -> red palette is applied
|
||||||
alpha = std::min(1.0f, lifeT / 0.08f) * std::pow(1.0f - lifeT, 1.3f);
|
// per pixel in particles.frag from heat (center + youth).
|
||||||
size = p.size * (0.75f + 0.5f * std::sin(3.1416f * std::min(lifeT * 1.2f, 1.0f))) * (1.0f - 0.5f * lifeT);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case Kind::Ember: {
|
case Kind::Ember: {
|
||||||
@@ -222,6 +214,13 @@ void ParticleSystem::update(float dt, float time) {
|
|||||||
++i;
|
++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_) {
|
if (!vao_) {
|
||||||
glCreateVertexArrays(1, &vao_);
|
glCreateVertexArrays(1, &vao_);
|
||||||
glCreateBuffers(1, &vbo_);
|
glCreateBuffers(1, &vbo_);
|
||||||
|
|||||||
@@ -185,12 +185,13 @@ struct Renderer::Impl {
|
|||||||
void drawModels(const Scene& scene, int pass, bool reflectionPass) {
|
void drawModels(const Scene& scene, int pass, bool reflectionPass) {
|
||||||
Shader& s = pass == 1 ? meshDepth : mesh;
|
Shader& s = pass == 1 ? meshDepth : mesh;
|
||||||
s.use();
|
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) {
|
for (const ModelBatch* b : scene.models) {
|
||||||
if (!b || !b->model) continue;
|
if (!b || !b->model) continue;
|
||||||
if (pass == 1 && !b->castShadows) continue;
|
if (pass == 1 && !b->castShadows) continue;
|
||||||
if (reflectionPass && !b->inReflections) continue;
|
if (reflectionPass && !b->inReflections) continue;
|
||||||
s.set("uWindScale", b->windScale);
|
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) {
|
for (size_t i = 0; i < b->model->primitives.size(); ++i) {
|
||||||
const ModelMaterial& m = *b->model->primitives[i].material;
|
const ModelMaterial& m = *b->model->primitives[i].material;
|
||||||
s.set("uAlphaMask", m.alphaMask ? 1 : 0);
|
s.set("uAlphaMask", m.alphaMask ? 1 : 0);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ Weather::Weather() {
|
|||||||
storm.windStrength = 0.2f;
|
storm.windStrength = 0.2f;
|
||||||
storm.windGustSpeed = 3.0f;
|
storm.windGustSpeed = 3.0f;
|
||||||
storm.bloomStrength = 0.06f;
|
storm.bloomStrength = 0.06f;
|
||||||
storm.saturation = 0.82f;
|
storm.saturation = 0.92f;
|
||||||
}
|
}
|
||||||
|
|
||||||
WeatherLook Weather::capture(const Scene& s, const RenderSettings& r) {
|
WeatherLook Weather::capture(const Scene& s, const RenderSettings& r) {
|
||||||
|
|||||||
@@ -431,9 +431,11 @@ def make_bridge(name, seed):
|
|||||||
# Arch soffit, seen in the water reflection.
|
# 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,
|
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)
|
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
|
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)]
|
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)
|
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.
|
# Cutwaters on the piers, posts along the parapets.
|
||||||
|
|||||||
Reference in New Issue
Block a user