Adopt Shapespark vegetation assets
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user