256 lines
9.7 KiB
Python
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)
|