tree_style 新增 apple(SpeedTree Red Delicious,4475 tris,alpha 抠图叶片 + 法线贴图)和 fattree(低面数卡通树,2238 tris,实体几何)。 高度改为按模型自身包围盒归一化到目标高度,树根落在 z=0,不再用魔数; 每棵树按序号做确定性抖动(缩放 ±14%、黄金角偏航、±3° 倾斜), tree_row 采样高度全部相同,不抖动就是同一棵树盖章 156 次。 移除 polyhaven 样式和 island_tree_01 资产(76MB)+ ingest_tree.py: 该样式 append 的 *_LOD1 并不是完整的树,枝干只有 0.41 单位高,叶片是 挂在原点下方的平面簇,本是给源文件几何节点散布用的碎片。 Cesium 侧修三处: - make_export_material 把 Alpha 恒定写死为 1.0,叶片卡片整块导出,而 SpeedTree 图集抠掉的区域是纯黑,在 Cesium 里就是黑色色块。 - Blender 4.2 起 glTF 导出不再读 blend_method(仍可写但已失效,写 CLIP 读回来是 HASHED),改为从节点树推断 alpha 模式,alpha 直连 BSDF 会落到 BLEND。新增 materials.link_alpha_clip() 构造导出器识别的 1 - (alpha < cutoff) 结构,得到 alphaMode=MASK。 - 新增 alpha_dilated_image():把不透明像素颜色向抠图区域外扩 8 圈, 避免 mipmap 把黑色平均进叶缘。叶缘相邻的纯黑像素 10.3% → 0.6%。 另修两个既有 bug: - 材质槽在 mesh 上,181 棵树共享一个 datablock,第一棵替换后其余会把结果 再包一层,产生 Cesium Cesium Cesium... 的材质名;烘焙图缓存按材质名索引, 每轮再嵌一份同样的贴图。GLB 22.46MB → 20.76MB,materials 270 → 23。 - shrub_02 从 glTF 带进来一个 Math 节点接在 Alpha 上,但其 JPEG 贴图 alpha 全是 1.0,只判断「连了 Alpha」会误判为抠图材质。 模型资产为第三方素材,已 gitignore,缺失时回落到 natural; 来源见 assets/models/SOURCES.md。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
319 lines
12 KiB
Python
319 lines
12 KiB
Python
"""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.
|
|
|
|
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.
|
|
|
|
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.
|
|
"""
|
|
|
|
import math
|
|
import os
|
|
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")
|
|
|
|
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.
|
|
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.
|
|
GOLDEN_TURN = 0.61803398875
|
|
SCALE_JITTER = 0.14
|
|
TILT_JITTER = math.radians(3.0)
|
|
|
|
|
|
# meshes — mesh datablocks instanced together at one transform
|
|
# height — the variant's own height, what a target height is divided by
|
|
# base_z — the variant's own ground line, what drops the trunk onto z=0
|
|
TreeVariant = namedtuple("TreeVariant", "meshes height base_z")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# import plumbing
|
|
|
|
|
|
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.
|
|
"""
|
|
mesh = obj.data.copy()
|
|
mesh.transform(obj.matrix_world.to_3x3().to_4x4())
|
|
mesh.name = name
|
|
mesh.use_fake_user = True
|
|
return mesh
|
|
|
|
|
|
def _measure(meshes):
|
|
"""Return (height, base_z) for a variant's meshes in their shared space."""
|
|
zs = [vertex.co.z for mesh in meshes for vertex in mesh.vertices]
|
|
if not zs:
|
|
return 1.0, 0.0
|
|
low, high = min(zs), max(zs)
|
|
return max(high - low, 1e-6), low
|
|
|
|
|
|
def _discard(objects):
|
|
"""Remove imported objects along with the meshes they brought in.
|
|
|
|
The _bake copies carry a fake user and survive. Dropping only the objects
|
|
would strand their original meshes at zero users, which in turn keeps the
|
|
source materials and their megabytes of texture alive in the file.
|
|
"""
|
|
for obj in objects:
|
|
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)
|
|
|
|
|
|
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.
|
|
"""
|
|
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)
|
|
|
|
|
|
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 _foliage_material(name, color_path, normal_path=None, alpha_clip=False,
|
|
roughness=0.72):
|
|
"""Principled setup for a textured tree, alpha-clipped when asked.
|
|
|
|
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.
|
|
"""
|
|
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)
|
|
return material
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# loaders — each 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 []
|
|
|
|
# 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.
|
|
"""
|
|
if not os.path.exists(FATTREE_BLEND):
|
|
return []
|
|
|
|
before_objects = set(bpy.data.objects)
|
|
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:
|
|
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:
|
|
_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)]
|
|
|
|
|
|
LOADERS = {
|
|
"apple": _load_apple,
|
|
"fattree": _load_fattree,
|
|
}
|
|
|
|
# The styles this module can serve, for the CLI to validate against.
|
|
MODEL_STYLES = tuple(LOADERS)
|
|
|
|
|
|
def assemble(positions, collection, style="apple"):
|
|
"""Place instanced trees of `style` at `positions`.
|
|
|
|
positions is a list of (x, y, height) tuples as gathered by the two
|
|
tree-collecting loops (point nodes + tree_row samples). `height` is the
|
|
OSM height where tagged and a constant default otherwise, which means every
|
|
sample along one tree_row arrives with an identical value — the per-index
|
|
jitter below is what stops a row of forty from reading as one tree stamped
|
|
forty times.
|
|
|
|
Returns the number of trees placed, or 0 when the style's model is absent
|
|
or unknown, which is the caller's signal to fall back to procedural trees.
|
|
"""
|
|
loader = LOADERS.get(style)
|
|
if loader is None:
|
|
return 0
|
|
variants = loader()
|
|
if not variants:
|
|
return 0
|
|
|
|
for index, (x, y, target_height) in enumerate(positions):
|
|
variant = variants[index % 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.
|
|
scale_wobble = 1.0 + SCALE_JITTER * math.sin(index * 2.399963)
|
|
target = max(MIN_TREE_HEIGHT, target_height) * scale_wobble
|
|
factor = target / variant.height
|
|
yaw = ((index * GOLDEN_TURN) % 1.0) * math.tau
|
|
tilt_x = TILT_JITTER * math.sin(index * 1.114517)
|
|
tilt_y = TILT_JITTER * math.cos(index * 0.927295)
|
|
# Scaled and negated, the variant's own ground line drops the trunk
|
|
# onto z=0 whatever the source file used as its origin.
|
|
z = -variant.base_z * factor
|
|
|
|
for slot, mesh in enumerate(variant.meshes):
|
|
obj = bpy.data.objects.new(
|
|
"Tree_%s_%04d_%d" % (style, index, slot), mesh)
|
|
obj.location = (x, y, z)
|
|
obj.scale = (factor, factor, factor)
|
|
obj.rotation_euler = (tilt_x, tilt_y, yaw)
|
|
collection.objects.link(obj)
|
|
|
|
tris = 0
|
|
for variant in variants:
|
|
for mesh in variant.meshes:
|
|
mesh.calc_loop_triangles()
|
|
tris += len(mesh.loop_triangles)
|
|
print("Tree style %r: %d variants, %d tris per instance, %d planted"
|
|
% (style, len(variants), tris // max(1, len(variants)), len(positions)))
|
|
return len(positions)
|