Adopt Shapespark vegetation assets

This commit is contained in:
2026-08-03 17:10:17 +08:00
parent cd9cde0127
commit dbb5705d8e
135 changed files with 9401 additions and 737 deletions

View File

@@ -1,26 +1,27 @@
"""Instanced tree assets — the model side of `--tree-style`.
Two vendored models, reduced to one runtime shape: import once, bake the source
object's orientation into a mesh copy, measure it, then link 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.
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.
apple SpeedTree Red Delicious, 4.5k tris, alpha-cut leaf cards
fattree low-poly cartoon tree, 2.2k tris, opaque geometry
Materials are rebuilt here rather than taken from the source files, because
neither arrives usable. 57% of the apple's colour texture is transparent —
those are leaf cards, and without an alpha-clipped setup the crown renders as a
solid ball of intersecting quads. fattree ships a bare Diffuse BSDF and a
texture path that only resolves next to the original .blend.
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 these two models. Removed along with the 78MB asset.
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
@@ -29,26 +30,21 @@ from collections import namedtuple
import bpy
from osmassets.materials import link_alpha_clip
MODEL_ROOT = os.path.abspath(os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "..",
"assets", "models",
))
APPLE_DIR = os.path.join(MODEL_ROOT, "speedtree", "apple_low")
APPLE_OBJ = os.path.join(APPLE_DIR, "RedDeliciousApple.obj")
APPLE_COLOR = os.path.join(APPLE_DIR, "textures", "apple_color_2k.png")
APPLE_NORMAL = os.path.join(APPLE_DIR, "textures", "apple_normal_2k.png")
FATTREE_DIR = os.path.join(MODEL_ROOT, "lyrog", "fattree")
FATTREE_BLEND = os.path.join(FATTREE_DIR, "fattree.blend")
FATTREE_COLOR = os.path.join(FATTREE_DIR, "textures", "fat_tree.png")
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
# Leaf cards are cut at half opacity: the apple atlas's alpha is near-binary
# already, so a different threshold only changes edge thickness.
# 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.
@@ -71,13 +67,11 @@ def _bake(obj, name):
"""Copy obj's mesh with its rotation and scale applied, but not its
position.
Orientation has to be baked: the OBJ importer leaves the apple's Y-up to
Z-up conversion sitting on the object, so a raw mesh copy would plant the
tree on its side. Position must *not* be, because a source file's
translation is where the artist parked the model in their own scene —
fattree sits 2.9m up in the air — and baking that in would offset every
instance by it. Dropping it costs nothing: `base_z` measures whatever
ground line the mesh ends up with, and assemble() corrects for it.
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())
@@ -112,9 +106,9 @@ def _discard(objects):
def _purge_orphans(before_materials, before_images):
"""Drop the materials and images an import created that nothing now uses.
Both importers build a material from the source file's own description and
load its textures. We replace that material, so without this the .blend
ships a second, unreferenced copy of every 2k texture.
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:
@@ -124,148 +118,91 @@ def _purge_orphans(before_materials, before_images):
bpy.data.images.remove(image)
def _image(path, non_color=False):
"""Load a texture once, keyed by filename so repeat calls share it."""
key = os.path.basename(path)
image = bpy.data.images.get(key)
if image is None:
image = bpy.data.images.load(path)
image.name = key
if non_color:
image.colorspace_settings.name = "Non-Color"
return image
def _base_name(name):
if len(name) > 4 and name[-4] == "." and name[-3:].isdigit():
return name[:-4]
return name
def _foliage_material(name, color_path, normal_path=None, alpha_clip=False,
roughness=0.72):
"""Principled setup for a textured tree, alpha-clipped when asked.
def _dedupe_material(material):
"""Reuse an already-loaded material with the same base name.
The cut-out goes through materials.link_alpha_clip rather than straight
into the Alpha socket — see that function for why the extra two nodes are
what makes the crown survive the trip to Cesium.
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.
"""
material = bpy.data.materials.get(name)
if material:
return material
material = bpy.data.materials.new(name)
material.use_nodes = True
nodes = material.node_tree.nodes
links = material.node_tree.links
bsdf = next(n for n in nodes if n.type == "BSDF_PRINCIPLED")
bsdf.inputs["Roughness"].default_value = roughness
bsdf.inputs["Metallic"].default_value = 0.0
color_tex = nodes.new("ShaderNodeTexImage")
color_tex.image = _image(color_path)
color_tex.location = (-540, 260)
links.new(color_tex.outputs["Color"], bsdf.inputs["Base Color"])
if normal_path and os.path.exists(normal_path):
normal_tex = nodes.new("ShaderNodeTexImage")
normal_tex.image = _image(normal_path, non_color=True)
normal_tex.location = (-540, -140)
normal_map = nodes.new("ShaderNodeNormalMap")
normal_map.location = (-250, -140)
links.new(normal_tex.outputs["Color"], normal_map.inputs["Color"])
links.new(normal_map.outputs["Normal"], bsdf.inputs["Normal"])
if alpha_clip:
link_alpha_clip(material, color_tex.outputs["Alpha"], bsdf,
cutoff=ALPHA_CUTOFF)
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
# --------------------------------------------------------------------------
# loaders each returns [] when its model is absent, so a clean checkout
# still builds and the caller falls back to procedural trees
# loader — returns [] when its model is absent, so a clean checkout still builds
# and the caller falls back to procedural trees
def _load_apple():
"""SpeedTree Red Delicious: one mesh, alpha-cut leaf cards, normal-mapped."""
if not os.path.exists(APPLE_OBJ):
return []
def _load_shapespark():
"""Shapespark low-poly plant kit: 12 alpha-cut tree variants.
# Build ours first: the OBJ importer reuses an already-loaded image when the
# .mtl resolves to the same file, so the 2k textures land in the file once.
material = _foliage_material("AppleTree", APPLE_COLOR, APPLE_NORMAL,
alpha_clip=True, roughness=0.68)
before_objects = set(bpy.data.objects)
before_materials = set(bpy.data.materials)
before_images = set(bpy.data.images)
bpy.ops.wm.obj_import(filepath=APPLE_OBJ)
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(imported):
mesh = _bake(obj, "AppleTree_%02d" % index)
mesh.materials.clear()
mesh.materials.append(material)
meshes.append(mesh)
height, base_z = _measure(meshes)
_discard(imported)
_purge_orphans(before_materials, before_images)
return [TreeVariant(meshes, height, base_z)]
def _load_fattree():
"""Low-poly cartoon tree: opaque geometry, one diffuse texture.
The crown is real geometry and the texture's alpha is 1.0 everywhere, so
unlike the apple this needs no cut-out — and no normal map, which the
source does not ship.
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.
"""
if not os.path.exists(FATTREE_BLEND):
return []
before_objects = set(bpy.data.objects)
variants = []
before_materials = set(bpy.data.materials)
before_images = set(bpy.data.images)
try:
bpy.ops.wm.append(
filepath=FATTREE_BLEND + "/Object/fattree",
directory=FATTREE_BLEND + "/Object/",
files=[{"name": "fattree"}],
link=False,
)
except RuntimeError:
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 []
imported = [obj for obj in set(bpy.data.objects) - before_objects
if obj.type == "MESH"]
obj = bpy.data.objects.get("fattree")
if obj is None:
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 []
material = _foliage_material("FatTree", FATTREE_COLOR, alpha_clip=False,
roughness=0.85)
mesh = _bake(obj, "FatTree")
mesh.materials.clear()
mesh.materials.append(material)
height, base_z = _measure([mesh])
_discard(imported)
_purge_orphans(before_materials, before_images)
return [TreeVariant([mesh], height, base_z)]
return variants
LOADERS = {
"apple": _load_apple,
"fattree": _load_fattree,
"shapespark": _load_shapespark,
}
# The styles this module can serve, for the CLI to validate against.
MODEL_STYLES = tuple(LOADERS)
def assemble(positions, collection, style="apple"):
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
@@ -286,7 +223,7 @@ def assemble(positions, collection, style="apple"):
return 0
for index, (x, y, target_height) in enumerate(positions):
variant = variants[index % len(variants)]
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.