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

+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