Files

256 lines
9.7 KiB
Python

"""Instanced tree assets — the model side of `--tree-style`.
The Shapespark low-poly plant kit is reduced to runtime variants by importing
each split glTF once, baking the source object's orientation into a mesh copy,
measuring it, then linking one lightweight object per tree that reuses that
datablock. Nothing is duplicated per tree, so the .blend and the exported GLB
carry each mesh and each texture exactly once no matter how many trees are
planted.
Materials from split glTF files are reused by base name because Blender creates
`branch-01.001`, `branch-01.002`, ... duplicates across imports even when the
images are shared by filepath. Deduping material slots keeps the Cesium exporter
from baking the same foliage texture many times.
A third style, `polyhaven`, used to live here. It appended what the Poly Haven
island_tree_01 file calls its LOD1 objects, but those are not whole trees: the
branch parts are 0.4-unit twigs and the leaf parts are flat clusters hanging
below their own origin, both meant to be scattered by the geometry-nodes setup
in that file. Planting them directly gave twigs, which is what sent us looking
for other tree assets. Removed along with the 78MB asset.
Older model tree styles were also retired after Shapespark trees proved
visually better and lighter for the Cesium preview. Their detailed experiment
notes live in `docs/changelog.md` and the Trellis asset-generation spec.
"""
import math
import os
from collections import namedtuple
import bpy
MODEL_ROOT = os.path.abspath(os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "..",
"assets", "models",
))
SHAPESPARK_DIR = os.path.join(MODEL_ROOT, "custom", "shapespark_plants")
SHAPESPARK_TREE_IDS = (
"tree-01-1", "tree-01-2", "tree-01-3", "tree-01-4",
"tree-02-1", "tree-02-2", "tree-02-3", "tree-02-4",
"tree-03-1", "tree-03-2", "tree-03-3", "tree-03-4",
)
MIN_TREE_HEIGHT = 4.0
# Shapespark leaf cards use alpha-cut foliage textures.
ALPHA_CUTOFF = 0.5
# Golden angle. Successive trees face directions that never repeat and never
# settle into a pattern, so a tree_row reads as planted rather than stamped.
GOLDEN_TURN = 0.61803398875
SCALE_JITTER = 0.14
TILT_JITTER = math.radians(3.0)
# meshes — mesh datablocks instanced together at one transform
# height — the variant's own height, what a target height is divided by
# base_z — the variant's own ground line, what drops the trunk onto z=0
TreeVariant = namedtuple("TreeVariant", "meshes height base_z")
# --------------------------------------------------------------------------
# import plumbing
def _bake(obj, name):
"""Copy obj's mesh with its rotation and scale applied, but not its
position.
Orientation has to be baked because imported glTF objects may carry source
rotations or scale on the object. Position must not be baked: a source
file's translation is where the artist parked the model in its own scene,
and `base_z` measures whatever ground line the mesh ends up with so
assemble() can correct for it per instance.
"""
mesh = obj.data.copy()
mesh.transform(obj.matrix_world.to_3x3().to_4x4())
mesh.name = name
mesh.use_fake_user = True
return mesh
def _measure(meshes):
"""Return (height, base_z) for a variant's meshes in their shared space."""
zs = [vertex.co.z for mesh in meshes for vertex in mesh.vertices]
if not zs:
return 1.0, 0.0
low, high = min(zs), max(zs)
return max(high - low, 1e-6), low
def _discard(objects):
"""Remove imported objects along with the meshes they brought in.
The _bake copies carry a fake user and survive. Dropping only the objects
would strand their original meshes at zero users, which in turn keeps the
source materials and their megabytes of texture alive in the file.
"""
for obj in objects:
mesh = obj.data if obj.type == "MESH" else None
bpy.data.objects.remove(obj, do_unlink=True)
if mesh is not None and mesh.users == 0:
bpy.data.meshes.remove(mesh)
def _purge_orphans(before_materials, before_images):
"""Drop the materials and images an import created that nothing now uses.
glTF import creates temporary datablocks for source material descriptions.
Once split-asset duplicates are collapsed, unused leftovers should not stay
packed into the .blend.
"""
for material in set(bpy.data.materials) - before_materials:
if material.users == 0:
bpy.data.materials.remove(material)
for image in set(bpy.data.images) - before_images:
if image.users == 0:
bpy.data.images.remove(image)
def _base_name(name):
if len(name) > 4 and name[-4] == "." and name[-3:].isdigit():
return name[:-4]
return name
def _dedupe_material(material):
"""Reuse an already-loaded material with the same base name.
Importing many split Shapespark glTFs shares image datablocks by filepath,
but still creates `branch-01.001`, `branch-01.002`, ... material copies.
Those names would make the Cesium exporter bake duplicate export materials,
so collapse them back to the first material for each base name.
"""
name = _base_name(material.name)
existing = bpy.data.materials.get(name)
if existing and existing is not material:
return existing
material.name = name
if getattr(material, "blend_method", "OPAQUE") == "BLEND":
material.blend_method = "HASHED"
material.alpha_threshold = ALPHA_CUTOFF
material.show_transparent_back = False
return material
# --------------------------------------------------------------------------
# loader — returns [] when its model is absent, so a clean checkout still builds
# and the caller falls back to procedural trees
def _load_shapespark():
"""Shapespark low-poly plant kit: 12 alpha-cut tree variants.
The split assets keep one glTF/bin per plant and a shared texture directory.
Each variant is imported once, baked into a mesh datablock, then its source
object is discarded; individual OSM trees link those datablocks.
"""
variants = []
before_materials = set(bpy.data.materials)
before_images = set(bpy.data.images)
paths = [
os.path.join(SHAPESPARK_DIR, tree_id, "model.gltf")
for tree_id in SHAPESPARK_TREE_IDS
]
if not all(os.path.exists(path) for path in paths):
return []
for tree_id, path in zip(SHAPESPARK_TREE_IDS, paths):
before_objects = set(bpy.data.objects)
try:
bpy.ops.import_scene.gltf(filepath=path)
except RuntimeError:
return []
imported = [obj for obj in set(bpy.data.objects) - before_objects
if obj.type == "MESH"]
if not imported:
return []
meshes = []
for index, obj in enumerate(sorted(imported, key=lambda item: item.name)):
mesh = _bake(obj, "Shapespark_%s_%02d" % (tree_id, index))
for slot, material in enumerate(mesh.materials):
if material is not None:
mesh.materials[slot] = _dedupe_material(material)
meshes.append(mesh)
height, base_z = _measure(meshes)
variants.append(TreeVariant(meshes, height, base_z))
_discard(imported)
_purge_orphans(before_materials, before_images)
return variants
LOADERS = {
"shapespark": _load_shapespark,
}
# The styles this module can serve, for the CLI to validate against.
MODEL_STYLES = tuple(LOADERS)
def assemble(positions, collection, style="shapespark"):
"""Place instanced trees of `style` at `positions`.
positions is a list of (x, y, height) tuples as gathered by the two
tree-collecting loops (point nodes + tree_row samples). `height` is the
OSM height where tagged and a constant default otherwise, which means every
sample along one tree_row arrives with an identical value — the per-index
jitter below is what stops a row of forty from reading as one tree stamped
forty times.
Returns the number of trees placed, or 0 when the style's model is absent
or unknown, which is the caller's signal to fall back to procedural trees.
"""
loader = LOADERS.get(style)
if loader is None:
return 0
variants = loader()
if not variants:
return 0
for index, (x, y, target_height) in enumerate(positions):
variant = variants[int(((index * GOLDEN_TURN) % 1.0) * len(variants))]
# Irrational periods stand in for an RNG: no repeat over any realistic
# tree count, and a pure function of the index, so rebuilding an area
# plants the identical forest.
scale_wobble = 1.0 + SCALE_JITTER * math.sin(index * 2.399963)
target = max(MIN_TREE_HEIGHT, target_height) * scale_wobble
factor = target / variant.height
yaw = ((index * GOLDEN_TURN) % 1.0) * math.tau
tilt_x = TILT_JITTER * math.sin(index * 1.114517)
tilt_y = TILT_JITTER * math.cos(index * 0.927295)
# Scaled and negated, the variant's own ground line drops the trunk
# onto z=0 whatever the source file used as its origin.
z = -variant.base_z * factor
for slot, mesh in enumerate(variant.meshes):
obj = bpy.data.objects.new(
"Tree_%s_%04d_%d" % (style, index, slot), mesh)
obj.location = (x, y, z)
obj.scale = (factor, factor, factor)
obj.rotation_euler = (tilt_x, tilt_y, yaw)
collection.objects.link(obj)
tris = 0
for variant in variants:
for mesh in variant.meshes:
mesh.calc_loop_triangles()
tris += len(mesh.loop_triangles)
print("Tree style %r: %d variants, %d tris per instance, %d planted"
% (style, len(variants), tris // max(1, len(variants)), len(positions)))
return len(positions)