Adopt Shapespark vegetation assets
This commit is contained in:
@@ -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 Delicious,4.5k tris,叶片是 alpha 抠图卡片,带法线贴图。
|
||||
- `fattree`(卡通)——低面数卡通树,2.2k tris,纯实体几何,无抠图。
|
||||
- `shapespark`——Shapespark low-poly plant kit,12 个确定性树变体,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`:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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