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