diff --git a/CHANGELOG.md b/CHANGELOG.md index af1b8ed..aaa53aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/MEMORY.md b/MEMORY.md index 25137b2..8c082ec 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -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 diff --git a/TODO.md b/TODO.md index dc0a664..c2e0275 100644 --- a/TODO.md +++ b/TODO.md @@ -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. diff --git a/assets/models/blender/manifest.json b/assets/models/blender/manifest.json index 5dd022f..38084c4 100644 --- a/assets/models/blender/manifest.json +++ b/assets/models/blender/manifest.json @@ -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", diff --git a/assets/models/blender/props/planter_box.glb b/assets/models/blender/props/planter_box.glb index 4ec5a61..949bbc8 100644 Binary files a/assets/models/blender/props/planter_box.glb and b/assets/models/blender/props/planter_box.glb differ diff --git a/assets/models/blender/props/planter_box_ao.png b/assets/models/blender/props/planter_box_ao.png index 96e13f9..498f9c6 100644 Binary files a/assets/models/blender/props/planter_box_ao.png and b/assets/models/blender/props/planter_box_ao.png differ diff --git a/assets/models/blender/props/round_planter.glb b/assets/models/blender/props/round_planter.glb index 7d0f091..3ee905f 100644 Binary files a/assets/models/blender/props/round_planter.glb and b/assets/models/blender/props/round_planter.glb differ diff --git a/assets/models/blender/props/round_planter_ao.png b/assets/models/blender/props/round_planter_ao.png index 1c2f50f..f9c7854 100644 Binary files a/assets/models/blender/props/round_planter_ao.png and b/assets/models/blender/props/round_planter_ao.png differ diff --git a/assets/models/blender/props/street_tree.glb b/assets/models/blender/props/street_tree.glb index 4fcc6c5..ff83222 100644 Binary files a/assets/models/blender/props/street_tree.glb and b/assets/models/blender/props/street_tree.glb differ diff --git a/assets/models/blender/props/street_tree_ao.png b/assets/models/blender/props/street_tree_ao.png index 3e2642f..ad3557a 100644 Binary files a/assets/models/blender/props/street_tree_ao.png and b/assets/models/blender/props/street_tree_ao.png differ diff --git a/docs/3d-model-review.md b/docs/3d-model-review.md index 0510869..bc7cbb8 100644 --- a/docs/3d-model-review.md +++ b/docs/3d-model-review.md @@ -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), diff --git a/docs/blender_previews/sheet_props.jpg b/docs/blender_previews/sheet_props.jpg index bc7aaa0..e6e1592 100644 Binary files a/docs/blender_previews/sheet_props.jpg and b/docs/blender_previews/sheet_props.jpg differ diff --git a/tools/blender_assets.py b/tools/blender_assets.py index df5a194..51fe688 100644 --- a/tools/blender_assets.py +++ b/tools/blender_assets.py @@ -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