Rebuild the foliage with leaf cards: leafier silhouette at two thirds the triangles
Full build / Import + headless tests (push) Successful in 42s
Full build / Full desktop build + optional release (push) Skipped

This commit is contained in:
zwitschi committed 2026-11-06 20:02:32 +01:00
1 parent d3a87acf2d
commit 045ed2a58f
13 files changed
+114 -28

No files matched your search

+5
View File
@@ -8,6 +8,11 @@ Semantic Versioning.
### Changed
- Street greenery now reads as leaves instead of green rocks: the planter shrubs and tree crowns
are built from leaf cards, which also cut their triangle count by about a third.
### Changed
- Removed every hat and hood from the NPC roster: the firefighter helmet is the only head gear
left, so no character hides its face behind a cap. The street performer no longer wears the
cone hat and carries the baton and board in the hands instead.
+6
View File
@@ -130,6 +130,12 @@ canonical implemented-features list in `docs/development-status.md`.
gear library. The procedural library + MakeHuman head stay in the tree as the tested fallback
(`build --source procedural`), not in the shipped roster. Silhouette variety is the known
regression — three meshes for twelve roles — and the modular part library is the `0.9.10` fix.
- **Foliage is leaf cards, not sphere stacks (2026-11-06):** `_shrub()` is one low-poly inner mass
plus 18–53 two-triangle cards scattered on the canopy shell by a jittered Fibonacci sequence, each
rotated onto the canopy normal and tilted off it. No alpha texture, so the flat-colour library and
the Compatibility renderer are unaffected — and it is ~35 % cheaper than the sphere stack it
replaced. If a shrub ever reads as a rock again, the cards were probably rotated to the normal
without the random tilt (`spread`).
- **Roster is eleven outfits over twelve roles (2026-11-06):** hoodie, Casual, Worker, Suit,
Adventurer, Beach, Casual_2, Farmer, King, Punk (Swat is intaken but unused — it faces Godot −Z and
every yaw attempt broke the skin: node rotation turns the skin with it, object rotation collapses
+5 -2
View File
@@ -945,8 +945,11 @@ scorecard source, this milestone is the actionable list.
- [ ] Rebuild the ranked prop candidates from §D: the foliage-language P1
(`planter_box`/`round_planter` foliage, `street_tree`/`palm_tree`), plus the
`newspaper_box` slot and `bollard` collar if the budget allows.
- [ ] Replace the flat `_shrub()`/`_tree()` clumps with carded clusters and keep the 60k
library triangle budget; measure before/after.
- [x] Replace the flat `_shrub()` clumps with **leaf cards** (`_leaf_card()`), the foliage-language
P1. Each shrub is now one low-poly inner mass wrapped in 18–53 two-triangle cards scattered on
the canopy shell and tilted off its normal, so the silhouette is ragged instead of
rock-like — and it is *cheaper*: `planter_box` 1,324 → 818 tris, `round_planter`
1,240 → 808, `street_tree` 2,592 → 1,506. Props total 13,808 → 11,784 tris (budget 60k).
- [ ] Review the 18 gear pieces for silhouette and strap/buckle readability and rebuild only what
fails; re-run `fit` after every change.
- [ ] Verify prop colliders and navigation obstacles against the final silhouettes.
+15 -15
View File
@@ -975,7 +975,7 @@
"bounds_godot": {
"max": [
0.5,
1.08,
1.118,
0.5
],
"min": [
@@ -993,8 +993,8 @@
],
"path": "assets/models/blender/props/planter_box.glb",
"replaces": "generated/planter_box.glb",
"triangles": 1324,
"vertices": 2780
"triangles": 818,
"vertices": 1701
},
"protest_sign": {
"attach": "hand",
@@ -1049,14 +1049,14 @@
"round_planter": {
"bounds_godot": {
"max": [
0.432,
1.12,
0.399,
1.175,
0.36
],
"min": [
-0.36,
0,
-0.363
-0.376
]
},
"category": "props",
@@ -1066,8 +1066,8 @@
"foliage_dark"
],
"path": "assets/models/blender/props/round_planter.glb",
"triangles": 1240,
"vertices": 2418
"triangles": 808,
"vertices": 1522
},
"scba_tank": {
"attach": "back",
@@ -1221,14 +1221,14 @@
"street_tree": {
"bounds_godot": {
"max": [
1.179,
3.62,
1.102
1.15,
3.634,
1.053
],
"min": [
-0.941,
-1.041,
0,
-1.082
-1.08
]
},
"category": "props",
@@ -1240,8 +1240,8 @@
],
"path": "assets/models/blender/props/street_tree.glb",
"replaces": "WorldBuilder cone trees",
"triangles": 2592,
"vertices": 5620
"triangles": 1506,
"vertices": 3330
},
"sunglasses": {
"attach": "head",
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 52 KiB

+17
View File
@@ -285,6 +285,23 @@ The chosen s48 is the `head_proto` default; the rejected s24/s64 stay local unde
front/three-quarter/profile) is
[`blender_previews/sheet_head_proto_before_after.jpg`](blender_previews/sheet_head_proto_before_after.jpg).
## Foliage language P1 — resolved 2026-11-06
The ranked P1 was that shrubs read as rocks: `_shrub()` stacked spheres, so the silhouette was
smooth and the light response was a single facet per clump.
`_leaf_card()` replaces the stack: one low-poly inner mass for opacity, then 18–53 two-triangle
quads scattered over the canopy shell by a jittered Fibonacci distribution, each rotated onto the
canopy normal and tilted off it. No alpha texture, so the flat-colour library and the Compatibility
renderer are untouched.
| Prop | Before | After |
| --- | --- | --- |
| `planter_box` | 1,324 tris | 818 |
| `round_planter` | 1,240 tris | 808 |
| `street_tree` | 2,592 tris | 1,506 |
| Props total (20) | 13,808 | 11,784 |
## Scorecard skeleton
Verdicts use `qa-review` semantics: **P0** (blocker: wrong scale, broken normals, budget fail),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 132 KiB

+66 -11
View File
@@ -73,7 +73,7 @@ PROP_MAX_TRIS = 4000
try:
import bpy # type: ignore
import bmesh # type: ignore
from mathutils import Matrix, Vector # type: ignore
from mathutils import Euler, Matrix, Vector # type: ignore
except ImportError: # pragma: no cover - audit/sheet/list run outside Blender
bpy = None
@@ -2621,20 +2621,73 @@ def build_gear(name):
# Props (grounded, front = Blender -Y)
# ---------------------------------------------------------------------------
def _shrub(name, center, radii, mat, lumps=7, seed=1, lump=0.42):
"""Dense foliage cluster: many small flat-shaded clumps in two greens.
def _leaf_card(name, size, centre, normal, mat, rng, spread=0.5):
"""One flat leaf card: a 2-triangle quad, angled off the canopy normal.
Alternating light/dark clumps break the single-colour read that made the
old shrub look like a rock; the facets still catch the light at game
distance (the realism-pass foliage note).
Cards are what makes foliage read as leaves instead of a rock: the silhouette
becomes ragged and the light breaks across many small faces. They carry no
alpha texture, so the material stays in the flat-colour library and the
Compatibility renderer needs no transparency pass.
"""
bm = bmesh.new()
corners = [(-size, -size * 0.7), (size, -size * 0.7),
(size, size * 0.7), (-size, size * 0.7)]
verts = [bm.verts.new((x, y, 0.0)) for x, y in corners]
bm.faces.new(verts)
mesh = bpy.data.meshes.new(name)
bm.to_mesh(mesh)
bm.free()
obj = bpy.data.objects.new(name, mesh)
bpy.context.scene.collection.objects.link(obj)
# Face the card along the canopy normal, then tilt it so the cluster is not a
# shell of coplanar quads.
direction = Vector(normal)
if direction.length < 1e-6:
direction = Vector((0.0, 0.0, 1.0))
direction.normalize()
quat = Vector((0.0, 0.0, 1.0)).rotation_difference(direction)
tilt = Euler((rng.uniform(-spread, spread), rng.uniform(-spread, spread),
rng.uniform(0.0, math.tau)), "XYZ").to_quaternion()
obj.rotation_mode = "QUATERNION"
obj.rotation_quaternion = quat @ tilt
obj.location = centre
return _finish(obj, mat, smooth=False)
def _shrub(name, center, radii, mat, lumps=7, seed=1, lump=0.42):
"""Foliage cluster: a dark inner mass wrapped in leaf cards.
The old version was a stack of spheres, which read as a rock at distance. The
inner mass keeps the canopy opaque and the cards give it a leaf silhouette and
a broken light response; it also costs fewer triangles than the sphere stack.
"""
parts = []
import random
rng = random.Random(seed)
mat_light = material("foliage")
mat_dark = material("foliage_dark")
parts.append(sphere(name, 1.0, center, mat, seg=10, rings=7, scale=radii,
# Inner mass: one low-poly sphere at 72 % so the cards always have something
# opaque behind them.
parts.append(sphere(name, 1.0, center, mat, seg=8, rings=6,
scale=(radii[0] * 0.72, radii[1] * 0.72, radii[2] * 0.72),
smooth=False))
card_count = 18 + lumps * 7
card_size = max(radii) * 0.17
for i in range(card_count):
# Fibonacci-ish distribution over the ellipsoid, jittered.
t = (i + 0.5) / card_count
z = 1.0 - 2.0 * t
r = math.sqrt(max(0.0, 1.0 - z * z))
a = math.tau * i * 0.61803398875 + rng.uniform(-0.25, 0.25)
off = Vector((center[0] + math.cos(a) * radii[0] * r * rng.uniform(0.86, 1.02),
center[1] + math.sin(a) * radii[1] * r * rng.uniform(0.86, 1.02),
center[2] + z * radii[2] * rng.uniform(0.86, 1.02)))
normal = (off - Vector(center))
normal.x /= max(radii[0], 1e-4)
normal.y /= max(radii[1], 1e-4)
normal.z /= max(radii[2], 1e-4)
parts.append(_leaf_card(f"{name}Leaf", card_size * rng.uniform(0.75, 1.3), off,
normal, mat_light if i % 3 else mat_dark, rng))
for i in range(lumps * 2): # inner clumps fill the volume
a = math.tau * i / (lumps * 2) + rng.uniform(-0.25, 0.25)
ring = rng.uniform(0.25, 0.75)
@@ -2643,8 +2696,9 @@ def _shrub(name, center, radii, mat, lumps=7, seed=1, lump=0.42):
center[1] + math.sin(a) * radii[1] * ring,
center[2] + rng.uniform(-0.2, 0.55) * radii[2])
parts.append(sphere(name, 1.0, off, mat_light if i % 2 == 0 else mat_dark,
seg=8, rings=6,
scale=(radii[0] * r, radii[1] * r, radii[2] * r * 0.9),
seg=6, rings=4,
scale=(radii[0] * r * 0.8, radii[1] * r * 0.8,
radii[2] * r * 0.75),
smooth=False))
for i in range(max(3, lumps // 2)): # outer clumps break the silhouette
a = math.tau * i / max(3, lumps // 2) + rng.uniform(-0.3, 0.3)
@@ -2653,8 +2707,9 @@ def _shrub(name, center, radii, mat, lumps=7, seed=1, lump=0.42):
center[1] + math.sin(a) * radii[1] * rng.uniform(0.85, 1.0),
center[2] + rng.uniform(-0.05, 0.4) * radii[2])
parts.append(sphere(name, 1.0, off, mat_dark if i % 2 == 0 else mat_light,
seg=8, rings=6,
scale=(radii[0] * r, radii[1] * r, radii[2] * r * 0.8),
seg=6, rings=4,
scale=(radii[0] * r * 0.8, radii[1] * r * 0.8,
radii[2] * r * 0.7),
rot=(rng.uniform(0, 1), rng.uniform(0, 1), rng.uniform(0, 3)),
smooth=False))
return parts