Rebuild the foliage with leaf cards: leafier silhouette at two thirds the triangles
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 52 KiB |
@@ -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),
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 132 KiB |
@@ -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
|
||||||
|
|||||||