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 ### 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 - 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 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. 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 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 (`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. 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, - **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 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 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 - [ ] Rebuild the ranked prop candidates from §D: the foliage-language P1
(`planter_box`/`round_planter` foliage, `street_tree`/`palm_tree`), plus the (`planter_box`/`round_planter` foliage, `street_tree`/`palm_tree`), plus the
`newspaper_box` slot and `bollard` collar if the budget allows. `newspaper_box` slot and `bollard` collar if the budget allows.
- [ ] Replace the flat `_shrub()`/`_tree()` clumps with carded clusters and keep the 60k - [x] Replace the flat `_shrub()` clumps with **leaf cards** (`_leaf_card()`), the foliage-language
library triangle budget; measure before/after. 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 - [ ] Review the 18 gear pieces for silhouette and strap/buckle readability and rebuild only what
fails; re-run `fit` after every change. fails; re-run `fit` after every change.
- [ ] Verify prop colliders and navigation obstacles against the final silhouettes. - [ ] Verify prop colliders and navigation obstacles against the final silhouettes.
+15 -15
View File
@@ -975,7 +975,7 @@
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.5, 0.5,
1.08, 1.118,
0.5 0.5
], ],
"min": [ "min": [
@@ -993,8 +993,8 @@
], ],
"path": "assets/models/blender/props/planter_box.glb", "path": "assets/models/blender/props/planter_box.glb",
"replaces": "generated/planter_box.glb", "replaces": "generated/planter_box.glb",
"triangles": 1324, "triangles": 818,
"vertices": 2780 "vertices": 1701
}, },
"protest_sign": { "protest_sign": {
"attach": "hand", "attach": "hand",
@@ -1049,14 +1049,14 @@
"round_planter": { "round_planter": {
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.432, 0.399,
1.12, 1.175,
0.36 0.36
], ],
"min": [ "min": [
-0.36, -0.36,
0, 0,
-0.363 -0.376
] ]
}, },
"category": "props", "category": "props",
@@ -1066,8 +1066,8 @@
"foliage_dark" "foliage_dark"
], ],
"path": "assets/models/blender/props/round_planter.glb", "path": "assets/models/blender/props/round_planter.glb",
"triangles": 1240, "triangles": 808,
"vertices": 2418 "vertices": 1522
}, },
"scba_tank": { "scba_tank": {
"attach": "back", "attach": "back",
@@ -1221,14 +1221,14 @@
"street_tree": { "street_tree": {
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
1.179, 1.15,
3.62, 3.634,
1.102 1.053
], ],
"min": [ "min": [
-0.941, -1.041,
0, 0,
-1.082 -1.08
] ]
}, },
"category": "props", "category": "props",
@@ -1240,8 +1240,8 @@
], ],
"path": "assets/models/blender/props/street_tree.glb", "path": "assets/models/blender/props/street_tree.glb",
"replaces": "WorldBuilder cone trees", "replaces": "WorldBuilder cone trees",
"triangles": 2592, "triangles": 1506,
"vertices": 5620 "vertices": 3330
}, },
"sunglasses": { "sunglasses": {
"attach": "head", "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 front/three-quarter/profile) is
[`blender_previews/sheet_head_proto_before_after.jpg`](blender_previews/sheet_head_proto_before_after.jpg). [`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 ## Scorecard skeleton
Verdicts use `qa-review` semantics: **P0** (blocker: wrong scale, broken normals, budget fail), 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: try:
import bpy # type: ignore import bpy # type: ignore
import bmesh # 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 except ImportError: # pragma: no cover - audit/sheet/list run outside Blender
bpy = None bpy = None
@@ -2621,20 +2621,73 @@ def build_gear(name):
# Props (grounded, front = Blender -Y) # Props (grounded, front = Blender -Y)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _shrub(name, center, radii, mat, lumps=7, seed=1, lump=0.42): def _leaf_card(name, size, centre, normal, mat, rng, spread=0.5):
"""Dense foliage cluster: many small flat-shaded clumps in two greens. """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 Cards are what makes foliage read as leaves instead of a rock: the silhouette
old shrub look like a rock; the facets still catch the light at game becomes ragged and the light breaks across many small faces. They carry no
distance (the realism-pass foliage note). 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 = [] parts = []
import random import random
rng = random.Random(seed) rng = random.Random(seed)
mat_light = material("foliage") mat_light = material("foliage")
mat_dark = material("foliage_dark") 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)) 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 for i in range(lumps * 2): # inner clumps fill the volume
a = math.tau * i / (lumps * 2) + rng.uniform(-0.25, 0.25) a = math.tau * i / (lumps * 2) + rng.uniform(-0.25, 0.25)
ring = rng.uniform(0.25, 0.75) 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[1] + math.sin(a) * radii[1] * ring,
center[2] + rng.uniform(-0.2, 0.55) * radii[2]) 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, parts.append(sphere(name, 1.0, off, mat_light if i % 2 == 0 else mat_dark,
seg=8, rings=6, seg=6, rings=4,
scale=(radii[0] * r, radii[1] * r, radii[2] * r * 0.9), scale=(radii[0] * r * 0.8, radii[1] * r * 0.8,
radii[2] * r * 0.75),
smooth=False)) smooth=False))
for i in range(max(3, lumps // 2)): # outer clumps break the silhouette 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) 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[1] + math.sin(a) * radii[1] * rng.uniform(0.85, 1.0),
center[2] + rng.uniform(-0.05, 0.4) * radii[2]) 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, parts.append(sphere(name, 1.0, off, mat_dark if i % 2 == 0 else mat_light,
seg=8, rings=6, seg=6, rings=4,
scale=(radii[0] * r, radii[1] * r, radii[2] * r * 0.8), 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)), rot=(rng.uniform(0, 1), rng.uniform(0, 1), rng.uniform(0, 3)),
smooth=False)) smooth=False))
return parts return parts