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

@@ -24,14 +24,15 @@ npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json
```
`--geojson` 为可选参数。不提供时,道路使用简单的 OSM highway 折线,而非详细 osm2streets 几何。实际资产生成建议先跑 `intermediates` 阶段,为 Blender 提供 osm2streets 道路面、标线、斑马线和箭头。
`--tree-style` 可选 `apple``fattree``natural``procedural`
`--tree-style` 可选 `shapespark``natural``procedural`
- `apple`拟真——SpeedTree Red Delicious4.5k tris叶片是 alpha 抠图卡片,带法线贴图
- `fattree`卡通——低面数卡通树2.2k tris纯实体几何无抠图。
- `shapespark`——Shapespark low-poly plant kit12 个确定性树变体,alpha 抠图卡片。
- `natural`——低面数树干 + 多层不规则树冠,不依赖外部模型。
- `procedural`——最轻,球形树冠。
`apple``fattree` 依赖 `assets/models/` 下的第三方模型(已 gitignore。模型缺失时自动回落到 `natural`,干净 checkout 仍可构建;实际使用的样式记录在场景的 `tree_style_used` 属性里
`shapespark` 依赖 `assets/models/custom/shapespark_plants/` 下拆分后的第三方模型
模型缺失时自动回落到 `natural`,干净 checkout 仍可构建;实际使用的样式记录在场景的
`tree_style_used` 属性里。
使用 `--office-overrides` 指定一组 OSM way ID逗号分隔这些建筑将渲染为办公楼风格即使其 OSM 标签为 `building=industrial`

View File

@@ -37,15 +37,13 @@ FOLIAGE_EMISSION = 0.25
# Multiplier on a cut-out foliage albedo before export.
#
# This is the knob that actually controls how dark the trees read, and it
# exists because the apple atlas is genuinely dark: its green texels average
# sRGB (0.249, 0.35, 0.12), a deep forest green, and the bark is darker still.
# Rendered at true albedo that is correct — but nothing else in this scene is
# at true albedo. Every other material goes through Cesium export contracts
# (grass mixes 72% toward a bright green, the ribbed facade 86% toward white,
# 0.18 emission on the buildings), all hand-tuned against Cesium's washed-out
# default lighting. A new asset dropped in untuned is the one thing rendering
# honestly, and next to the rest it reads as black.
# This legacy profile exists for dark alpha-cut foliage from older .blend files.
# Rendered at true albedo those assets are correct, but nothing else in this
# scene is at true albedo. Every other material goes through Cesium export
# contracts (grass mixes 72% toward a bright green, the ribbed facade 86% toward
# white, 0.18 emission on the buildings), all hand-tuned against Cesium's
# washed-out default lighting. A new asset dropped in untuned is the one thing
# rendering honestly, and next to the rest it reads as black.
#
# A gain rather than a tint, because a tint is what the other materials use and
# it is wrong here: they are single-surface, this is an atlas holding leaves,
@@ -56,15 +54,26 @@ FOLIAGE_ALBEDO_GAIN = 2.1
# Saturation multiplier applied with the gain, around each texel's own
# luminance. The gain alone lifts the crown to the right brightness but leaves
# it reading grey-green at distance: this atlas is desaturated to begin with
# (mean saturation 0.22), and mip-averaging a crown mixes leaves with bark and
# sky-gaps, pulling it further toward neutral exactly when the tree gets small.
# it reading grey-green at distance: alpha-cut atlases are often desaturated to
# begin with, and mip-averaging a crown mixes leaves with bark and sky-gaps,
# pulling it further toward neutral exactly when the tree gets small.
#
# Scaling the distance from luminance pushes the leaves green without touching
# what is already neutral much, and without the hue shift a green tint would
# force on the trunk — bark just becomes a warmer brown, which it should be.
FOLIAGE_SATURATION = 1.75
# Shapespark foliage is already graded brighter than legacy cut-out foliage.
# Reusing the heavy gain/saturation/emission makes it read yellow-green and
# glowing in Cesium, so these card materials get a gentler export profile.
SHAPESPARK_FOLIAGE_ALBEDO_GAIN = 1.12
SHAPESPARK_FOLIAGE_SATURATION = 1.0
SHAPESPARK_FOLIAGE_EMISSION = 0.06
SHAPESPARK_FOLIAGE_PREFIXES = (
"branch-", "shrubbery-", "high-grass-", "hedge-",
"clover-", "flowers-",
)
# Legacy fallback for .blend files created before materials carried their own
# `cesium_export` custom property. New scenes should get these values from
# catalog.MATERIALS[*]["cesium"], serialized by osmassets.materials.from_spec().
@@ -184,11 +193,11 @@ def source_alpha_clipped(material):
"""Whether the source material carries its silhouette in a texture alpha.
Two conditions, because either alone gives a wrong answer. A link into the
Principled Alpha input is not enough: shrub_02 arrives from glTF with a
Math node wired there even though its JPEG diffuse is opaque, and taking
that at face value re-encodes an opaque texture as a PNG and makes Cesium
alpha-test 270 tufts for nothing. An alpha channel alone is not enough
either: fat_tree.png is RGBA with every texel at 1.0.
Principled Alpha input is not enough: retired glTF lawn assets arrived with
a Math node wired there even though the JPEG diffuse was opaque, and taking
that at face value re-encoded an opaque texture as a PNG and made Cesium
alpha-test hundreds of tufts for nothing. An alpha channel alone is not
enough either; it can be fully opaque.
So ask both — the author wired alpha, and the texture actually cuts.
"""
@@ -201,6 +210,17 @@ def source_alpha_clipped(material):
return diffuse is not None and image_has_cutout(diffuse)
def foliage_export_profile(material):
name = material.name.lower()
if name.startswith(SHAPESPARK_FOLIAGE_PREFIXES):
return (
SHAPESPARK_FOLIAGE_ALBEDO_GAIN,
SHAPESPARK_FOLIAGE_SATURATION,
SHAPESPARK_FOLIAGE_EMISSION,
)
return FOLIAGE_ALBEDO_GAIN, FOLIAGE_SATURATION, FOLIAGE_EMISSION
_CUTOUT_CACHE = {}
@@ -253,12 +273,12 @@ def alpha_dilated_image(source, name, threshold=0.5, passes=8, gain=1.0,
saturation=1.0):
"""Flood the opaque colour outward underneath the cut-out, and lift it.
SpeedTree writes pure black wherever a leaf card is cut away — 97% of the
apple atlas's transparent area is exactly (0, 0, 0). An alpha mask hides
that at full resolution, but Cesium mip-maps the texture and every mip
level averages those black texels into the leaf edges, so the crown grows a
dark fringe that thickens with distance. Blender's preview renders at mip
0 and never shows it, which is why this only surfaces in the viewer.
Some legacy leaf-card atlases write pure black wherever a card is cut away.
An alpha mask hides that at full resolution, but Cesium mip-maps the texture
and every mip level averages those black texels into the leaf edges, so the
crown grows a dark fringe that thickens with distance. Blender's preview
renders at mip 0 and never shows it, which is why this only surfaces in the
viewer.
Replacing the colour under the cut-out with its nearest opaque neighbours
leaves no black to bleed. Alpha is copied through untouched, so the
@@ -385,6 +405,7 @@ def make_export_material(material):
# Foliage that carries its silhouette in the texture's alpha has to keep
# that channel; every other material is flattened to opaque below.
alpha_clipped = source_alpha_clipped(material)
foliage_gain, foliage_saturation, foliage_emission = foliage_export_profile(material)
if material.name == "Tree Crown":
diffuse = tree_crown_image()
else:
@@ -394,7 +415,7 @@ def make_export_material(material):
safe_name = material.name.replace(" ", "_")
diffuse = alpha_dilated_image(
diffuse, f"{EXPORT_PREFIX}{safe_name} Dilated",
gain=FOLIAGE_ALBEDO_GAIN, saturation=FOLIAGE_SATURATION)
gain=foliage_gain, saturation=foliage_saturation)
if has_base_color_override:
diffuse = None
normal = None
@@ -454,7 +475,7 @@ def make_export_material(material):
elif "Emission" in bsdf.inputs:
links.new(diffuse_node.outputs["Color"], bsdf.inputs["Emission"])
if "Emission Strength" in bsdf.inputs:
bsdf.inputs["Emission Strength"].default_value = FOLIAGE_EMISSION
bsdf.inputs["Emission Strength"].default_value = foliage_emission
elif "Alpha" in bsdf.inputs:
bsdf.inputs["Alpha"].default_value = 1.0
return result

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)

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.