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

@@ -61,42 +61,39 @@ from osmassets import scrub as _scrub # noqa: E402
from osmassets import tree as _tree # noqa: E402
MODEL_ROOT = os.path.abspath(os.path.join(
os.path.dirname(__file__), "..", "assets", "models", "polyhaven"
))
CUSTOM_MODEL_ROOT = os.path.abspath(os.path.join(
os.path.dirname(__file__), "..", "assets", "models", "custom"
))
# Vendored under the name shrub_02, but the plant reads as a tufted grass, not
# as a bush: it belongs on the lawns. Scrub beds stay flat ground cover until a
# genuinely bush-shaped asset is sourced.
TUFT_MODEL = os.path.join(MODEL_ROOT, "shrub_02", "shrub_02_1k.gltf")
# Poly Haven ships it at ~27k triangles across four variants. The leaves are
# modelled as real geometry, so decimation eats them: at ~2.2k per variant the
# tufts render as bare twigs. Instancing makes the full mesh affordable anyway —
# every tuft shares one of four datablocks, so the scene and the exported GLB
# carry that geometry once no matter how many are scattered. 0 disables it.
# Shapespark grass variants replace the former high-detail lawn tuft asset.
# They are alpha-cut cards at about 10 tris each, so the shared geometry is
# tiny and each lawn instance links one of these datablocks.
TUFT_MODELS = (
os.path.join(CUSTOM_MODEL_ROOT, "shapespark_plants", "grass-01", "model.gltf"),
os.path.join(CUSTOM_MODEL_ROOT, "shapespark_plants", "grass-02", "model.gltf"),
os.path.join(CUSTOM_MODEL_ROOT, "shapespark_plants", "grass-03", "model.gltf"),
)
TUFT_TARGET_TRIS = 0
# The variants stand 1.17-1.68m tall natively, which is shrub height. Roughly a
# third of that lands in the 0.3-0.8m range real lawn tufts occupy.
# The variants stand 1.2-1.34m tall natively. Roughly a third of that lands in
# the 0.3-0.6m range real lawn tufts occupy.
TUFT_SCALE_RANGE = (0.26, 0.46)
TUFT_SPACING = 1.7
TUFT_LIMIT_PER_LAWN = 100
# The vendored diffuse is a grey-green leaf over brown stems. Dropped onto the
# The Shapespark diffuse is a grey-green leaf over brown stems. Dropped onto the
# saturated lawn as-is it reads as dead weeds, so the base colour is mixed
# toward the lawn tint, a shade brighter so the tufts still separate from it.
TUFT_TINT = (0.15, 0.52, 0.09)
TUFT_TINT_FACTOR = 0.66
# toward a cooler lawn tint without making the cards glow.
TUFT_TINT = (0.08, 0.30, 0.055)
TUFT_TINT_FACTOR = 0.22
SCRUB_BUSH_MODEL = os.path.join(CUSTOM_MODEL_ROOT, "bush", "bush.glb")
SCRUB_BUSH_MODEL = os.path.join(
CUSTOM_MODEL_ROOT, "shapespark_plants", "bush-03", "model.gltf")
SCRUB_BUSH_SPACING = 1.8
SCRUB_BUSH_INSET = 0.38
SCRUB_BUSH_LIMIT_PER_PATCH = 60
SCRUB_BUSH_HEIGHT = 1.575
SCRUB_BUSH_SCALE_JITTER = 0.16
SCRUB_BUSH_LEAF_TINT = (0.075, 0.31, 0.055)
SCRUB_BUSH_LEAF_TINT_FACTOR = 0.36
SCRUB_BUSH_LEAF_TINT = (0.055, 0.23, 0.045)
SCRUB_BUSH_LEAF_TINT_FACTOR = 0.18
SCRUB_BUSH_TRUNK_TINT = (0.13, 0.095, 0.055)
SCRUB_BUSH_TRUNK_TINT_FACTOR = 0.18
SCRUB_TREE_MIN_AREA = 45.0
@@ -105,9 +102,9 @@ SCRUB_TREE_EDGE_CLEARANCE = 2.8
SCRUB_TREE_LIMIT_PER_PATCH = 5
SCRUB_TREE_HEIGHT_RANGE = (4.6, 6.2)
# Tree styles. The two built from mesh batches live in this file; the rest are
# vendored models handled by osmassets.tree, which owns that list. A model style
# whose asset is missing falls back to "natural" rather than planting nothing.
# Tree styles. The two built from mesh batches live in this file; Shapespark
# model trees are handled by osmassets.tree. A model style whose asset is
# missing falls back to "natural" rather than planting nothing.
TREE_STYLES = frozenset(("natural", "procedural")) | frozenset(_tree.MODEL_STYLES)
@@ -296,54 +293,84 @@ def add_natural_tree_instances(positions, collection, trunk_material,
upper.finish()
def _numbered_base_name(name):
if len(name) > 4 and name[-4] == "." and name[-3:].isdigit():
return name[:-4]
return name
def _dedupe_imported_material(material, tinted, tint_factor):
name = _numbered_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 = 0.45
material.show_transparent_back = False
if material.name not in tinted:
tint_base_color(material, TUFT_TINT, tint_factor)
tinted.add(material.name)
return material
def load_tuft_variants():
"""Import the vendored Poly Haven plant once and return decimated meshes.
"""Import Shapespark grass variants and return shared tuft meshes.
Returns [] when the asset is missing so a clean checkout still builds; the
lawns then fall back to plain textured ground.
"""
if not os.path.exists(TUFT_MODEL):
return []
before = set(bpy.data.objects)
try:
bpy.ops.import_scene.gltf(filepath=TUFT_MODEL)
except (RuntimeError, AttributeError) as error:
print("Grass tuft import failed, lawns stay flat:", error)
if not all(os.path.exists(path) for path in TUFT_MODELS):
return []
imported = [obj for obj in set(bpy.data.objects) - before if obj.type == "MESH"]
before_materials = set(bpy.data.materials)
before_images = set(bpy.data.images)
variants = []
tinted = set()
for obj in sorted(imported, key=lambda item: item.name):
obj.data.calc_loop_triangles()
source_tris = len(obj.data.loop_triangles)
if TUFT_TARGET_TRIS and source_tris > TUFT_TARGET_TRIS:
modifier = obj.modifiers.new("TuftDecimate", "DECIMATE")
modifier.ratio = max(0.02, TUFT_TARGET_TRIS / source_tris)
bpy.context.view_layer.update()
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = bpy.data.meshes.new_from_object(evaluated)
else:
mesh = obj.data.copy()
mesh.name = "GrassTuft_" + obj.name
mesh.calc_loop_triangles()
# Nothing is saved yet at this point, so keep the datablock alive even
# if a scene ends up with no lawn polygons to instance it into.
mesh.use_fake_user = True
for material in mesh.materials:
# The vendored textures are JPEG with no alpha channel, so hashed
# transparency only costs sorting work in Blender and Cesium.
if hasattr(material, "blend_method"):
material.blend_method = "OPAQUE"
# All four variants share one material, so guard against stacking
# the mix node — and the tint with it — four times over.
if material.name not in tinted:
tint_base_color(material, TUFT_TINT, TUFT_TINT_FACTOR)
tinted.add(material.name)
variants.append(mesh)
for model_path in TUFT_MODELS:
before = set(bpy.data.objects)
try:
bpy.ops.import_scene.gltf(filepath=model_path)
except (RuntimeError, AttributeError) as error:
print("Grass tuft import failed, lawns stay flat:", error)
return []
for obj in imported:
bpy.data.objects.remove(obj, do_unlink=True)
imported = [obj for obj in set(bpy.data.objects) - before if obj.type == "MESH"]
for obj in sorted(imported, key=lambda item: item.name):
obj.data.calc_loop_triangles()
source_tris = len(obj.data.loop_triangles)
if TUFT_TARGET_TRIS and source_tris > TUFT_TARGET_TRIS:
modifier = obj.modifiers.new("TuftDecimate", "DECIMATE")
modifier.ratio = max(0.02, TUFT_TARGET_TRIS / source_tris)
bpy.context.view_layer.update()
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = bpy.data.meshes.new_from_object(evaluated)
else:
mesh = _bake_imported_mesh(obj, "GrassTuft_" + obj.name)
mesh.name = "GrassTuft_" + _numbered_base_name(obj.name)
mesh.calc_loop_triangles()
# Nothing is saved yet at this point, so keep the datablock alive
# even if a scene ends up with no lawn polygons to instance it into.
mesh.use_fake_user = True
for slot, material in enumerate(mesh.materials):
if material is not None:
mesh.materials[slot] = _dedupe_imported_material(
material, tinted, TUFT_TINT_FACTOR)
variants.append(mesh)
for obj in imported:
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)
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)
tris = sum(len(mesh.loop_triangles) for mesh in variants)
detail = f"decimated to ~{TUFT_TARGET_TRIS} tris each" if TUFT_TARGET_TRIS else "full detail"
@@ -462,7 +489,8 @@ def load_scrub_bush_variant():
if material is None:
continue
material_name = material.name.lower()
if "leaf" in material_name:
if ("leaf" in material_name or "branch" in material_name or
"shrubbery" in material_name or "hedge" in material_name):
tint_base_color(
material, SCRUB_BUSH_LEAF_TINT,
SCRUB_BUSH_LEAF_TINT_FACTOR)