feat: 草坪新增一些草
This commit is contained in:
BIN
assets/models/polyhaven/shrub_02/shrub_02.bin
Normal file
BIN
assets/models/polyhaven/shrub_02/shrub_02.bin
Normal file
Binary file not shown.
437
assets/models/polyhaven/shrub_02/shrub_02_1k.gltf
Normal file
437
assets/models/polyhaven/shrub_02/shrub_02_1k.gltf
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
{
|
||||||
|
"asset": {
|
||||||
|
"generator": "Khronos glTF Blender I/O v3.5.30",
|
||||||
|
"version": "2.0"
|
||||||
|
},
|
||||||
|
"scene": 0,
|
||||||
|
"scenes": [
|
||||||
|
{
|
||||||
|
"name": "Scene",
|
||||||
|
"nodes": [
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"mesh": 0,
|
||||||
|
"name": "shrub_02_a",
|
||||||
|
"rotation": [
|
||||||
|
0.12589912116527557,
|
||||||
|
-0.6947680711746216,
|
||||||
|
0.006097283214330673,
|
||||||
|
0.708102822303772
|
||||||
|
],
|
||||||
|
"translation": [
|
||||||
|
-1.5478131771087646,
|
||||||
|
-0.005240630358457565,
|
||||||
|
-0.02073405683040619
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": 1,
|
||||||
|
"name": "shrub_02_b",
|
||||||
|
"rotation": [
|
||||||
|
0.08358937501907349,
|
||||||
|
-0.746630847454071,
|
||||||
|
0.26227879524230957,
|
||||||
|
0.6056113243103027
|
||||||
|
],
|
||||||
|
"translation": [
|
||||||
|
0.0007123738760128617,
|
||||||
|
-0.006340932101011276,
|
||||||
|
-0.01680799387395382
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": 2,
|
||||||
|
"name": "shrub_02_c",
|
||||||
|
"rotation": [
|
||||||
|
0.12648986279964447,
|
||||||
|
-0.7129753232002258,
|
||||||
|
0.3943961560726166,
|
||||||
|
0.5657898783683777
|
||||||
|
],
|
||||||
|
"translation": [
|
||||||
|
1.5559492111206055,
|
||||||
|
-0.005484403111040592,
|
||||||
|
-0.006101916544139385
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mesh": 3,
|
||||||
|
"name": "shrub_02_d",
|
||||||
|
"rotation": [
|
||||||
|
0.047377295792102814,
|
||||||
|
-0.7383221983909607,
|
||||||
|
-0.300414502620697,
|
||||||
|
0.60198575258255
|
||||||
|
],
|
||||||
|
"translation": [
|
||||||
|
3.258664131164551,
|
||||||
|
-0.004767145495861769,
|
||||||
|
-0.005447957664728165
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"materials": [
|
||||||
|
{
|
||||||
|
"alphaCutoff": 0.5,
|
||||||
|
"alphaMode": "MASK",
|
||||||
|
"doubleSided": true,
|
||||||
|
"name": "shrub_02",
|
||||||
|
"normalTexture": {
|
||||||
|
"index": 0
|
||||||
|
},
|
||||||
|
"pbrMetallicRoughness": {
|
||||||
|
"baseColorTexture": {
|
||||||
|
"index": 1
|
||||||
|
},
|
||||||
|
"metallicFactor": 0,
|
||||||
|
"metallicRoughnessTexture": {
|
||||||
|
"index": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meshes": [
|
||||||
|
{
|
||||||
|
"name": "Plane.024",
|
||||||
|
"primitives": [
|
||||||
|
{
|
||||||
|
"attributes": {
|
||||||
|
"POSITION": 0,
|
||||||
|
"TEXCOORD_0": 1,
|
||||||
|
"NORMAL": 2
|
||||||
|
},
|
||||||
|
"indices": 3,
|
||||||
|
"material": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Plane.029",
|
||||||
|
"primitives": [
|
||||||
|
{
|
||||||
|
"attributes": {
|
||||||
|
"POSITION": 4,
|
||||||
|
"TEXCOORD_0": 5,
|
||||||
|
"NORMAL": 6
|
||||||
|
},
|
||||||
|
"indices": 7,
|
||||||
|
"material": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Plane.041",
|
||||||
|
"primitives": [
|
||||||
|
{
|
||||||
|
"attributes": {
|
||||||
|
"POSITION": 8,
|
||||||
|
"TEXCOORD_0": 9,
|
||||||
|
"NORMAL": 10
|
||||||
|
},
|
||||||
|
"indices": 11,
|
||||||
|
"material": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Plane.036",
|
||||||
|
"primitives": [
|
||||||
|
{
|
||||||
|
"attributes": {
|
||||||
|
"POSITION": 12,
|
||||||
|
"TEXCOORD_0": 13,
|
||||||
|
"NORMAL": 14
|
||||||
|
},
|
||||||
|
"indices": 15,
|
||||||
|
"material": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"textures": [
|
||||||
|
{
|
||||||
|
"sampler": 0,
|
||||||
|
"source": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sampler": 0,
|
||||||
|
"source": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sampler": 0,
|
||||||
|
"source": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"mimeType": "image/jpeg",
|
||||||
|
"name": "shrub_02_nor_gl",
|
||||||
|
"uri": "textures/shrub_02_nor_gl_1k.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mimeType": "image/jpeg",
|
||||||
|
"name": "shrub_02_diff",
|
||||||
|
"uri": "textures/shrub_02_diff_1k.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mimeType": "image/jpeg",
|
||||||
|
"name": "shrub_02_rough",
|
||||||
|
"uri": "textures/shrub_02_arm_1k.jpg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"accessors": [
|
||||||
|
{
|
||||||
|
"bufferView": 0,
|
||||||
|
"componentType": 5126,
|
||||||
|
"count": 5125,
|
||||||
|
"max": [
|
||||||
|
0.6156559586524963,
|
||||||
|
1.6821810007095337,
|
||||||
|
0.42684367299079895
|
||||||
|
],
|
||||||
|
"min": [
|
||||||
|
-0.9439988136291504,
|
||||||
|
-0.03012576885521412,
|
||||||
|
-0.863244354724884
|
||||||
|
],
|
||||||
|
"type": "VEC3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bufferView": 1,
|
||||||
|
"componentType": 5126,
|
||||||
|
"count": 5125,
|
||||||
|
"type": "VEC2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bufferView": 2,
|
||||||
|
"componentType": 5126,
|
||||||
|
"count": 5125,
|
||||||
|
"type": "VEC3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bufferView": 3,
|
||||||
|
"componentType": 5123,
|
||||||
|
"count": 22770,
|
||||||
|
"type": "SCALAR"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bufferView": 4,
|
||||||
|
"componentType": 5126,
|
||||||
|
"count": 3600,
|
||||||
|
"max": [
|
||||||
|
0.9647624492645264,
|
||||||
|
1.0844767093658447,
|
||||||
|
0.30848613381385803
|
||||||
|
],
|
||||||
|
"min": [
|
||||||
|
-0.4038015305995941,
|
||||||
|
-0.10226154327392578,
|
||||||
|
-1.0773800611495972
|
||||||
|
],
|
||||||
|
"type": "VEC3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bufferView": 5,
|
||||||
|
"componentType": 5126,
|
||||||
|
"count": 3600,
|
||||||
|
"type": "VEC2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bufferView": 6,
|
||||||
|
"componentType": 5126,
|
||||||
|
"count": 3600,
|
||||||
|
"type": "VEC3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bufferView": 7,
|
||||||
|
"componentType": 5123,
|
||||||
|
"count": 15726,
|
||||||
|
"type": "SCALAR"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bufferView": 8,
|
||||||
|
"componentType": 5126,
|
||||||
|
"count": 6346,
|
||||||
|
"max": [
|
||||||
|
1.287977695465088,
|
||||||
|
0.8719967007637024,
|
||||||
|
0.24143657088279724
|
||||||
|
],
|
||||||
|
"min": [
|
||||||
|
-0.8910231590270996,
|
||||||
|
-0.2947101294994354,
|
||||||
|
-1.22649085521698
|
||||||
|
],
|
||||||
|
"type": "VEC3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bufferView": 9,
|
||||||
|
"componentType": 5126,
|
||||||
|
"count": 6346,
|
||||||
|
"type": "VEC2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bufferView": 10,
|
||||||
|
"componentType": 5126,
|
||||||
|
"count": 6346,
|
||||||
|
"type": "VEC3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bufferView": 11,
|
||||||
|
"componentType": 5123,
|
||||||
|
"count": 27702,
|
||||||
|
"type": "SCALAR"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bufferView": 12,
|
||||||
|
"componentType": 5126,
|
||||||
|
"count": 3528,
|
||||||
|
"max": [
|
||||||
|
0.22877177596092224,
|
||||||
|
1.153072476387024,
|
||||||
|
0.5660104751586914
|
||||||
|
],
|
||||||
|
"min": [
|
||||||
|
-1.051486849784851,
|
||||||
|
-0.09016040712594986,
|
||||||
|
-0.41910019516944885
|
||||||
|
],
|
||||||
|
"type": "VEC3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bufferView": 13,
|
||||||
|
"componentType": 5126,
|
||||||
|
"count": 3528,
|
||||||
|
"type": "VEC2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bufferView": 14,
|
||||||
|
"componentType": 5126,
|
||||||
|
"count": 3528,
|
||||||
|
"type": "VEC3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bufferView": 15,
|
||||||
|
"componentType": 5123,
|
||||||
|
"count": 15564,
|
||||||
|
"type": "SCALAR"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"bufferViews": [
|
||||||
|
{
|
||||||
|
"buffer": 0,
|
||||||
|
"byteLength": 61500,
|
||||||
|
"byteOffset": 0,
|
||||||
|
"target": 34962
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"buffer": 0,
|
||||||
|
"byteLength": 41000,
|
||||||
|
"byteOffset": 61500,
|
||||||
|
"target": 34962
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"buffer": 0,
|
||||||
|
"byteLength": 61500,
|
||||||
|
"byteOffset": 102500,
|
||||||
|
"target": 34962
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"buffer": 0,
|
||||||
|
"byteLength": 45540,
|
||||||
|
"byteOffset": 164000,
|
||||||
|
"target": 34963
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"buffer": 0,
|
||||||
|
"byteLength": 43200,
|
||||||
|
"byteOffset": 209540,
|
||||||
|
"target": 34962
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"buffer": 0,
|
||||||
|
"byteLength": 28800,
|
||||||
|
"byteOffset": 252740,
|
||||||
|
"target": 34962
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"buffer": 0,
|
||||||
|
"byteLength": 43200,
|
||||||
|
"byteOffset": 281540,
|
||||||
|
"target": 34962
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"buffer": 0,
|
||||||
|
"byteLength": 31452,
|
||||||
|
"byteOffset": 324740,
|
||||||
|
"target": 34963
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"buffer": 0,
|
||||||
|
"byteLength": 76152,
|
||||||
|
"byteOffset": 356192,
|
||||||
|
"target": 34962
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"buffer": 0,
|
||||||
|
"byteLength": 50768,
|
||||||
|
"byteOffset": 432344,
|
||||||
|
"target": 34962
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"buffer": 0,
|
||||||
|
"byteLength": 76152,
|
||||||
|
"byteOffset": 483112,
|
||||||
|
"target": 34962
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"buffer": 0,
|
||||||
|
"byteLength": 55404,
|
||||||
|
"byteOffset": 559264,
|
||||||
|
"target": 34963
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"buffer": 0,
|
||||||
|
"byteLength": 42336,
|
||||||
|
"byteOffset": 614668,
|
||||||
|
"target": 34962
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"buffer": 0,
|
||||||
|
"byteLength": 28224,
|
||||||
|
"byteOffset": 657004,
|
||||||
|
"target": 34962
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"buffer": 0,
|
||||||
|
"byteLength": 42336,
|
||||||
|
"byteOffset": 685228,
|
||||||
|
"target": 34962
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"buffer": 0,
|
||||||
|
"byteLength": 31128,
|
||||||
|
"byteOffset": 727564,
|
||||||
|
"target": 34963
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"samplers": [
|
||||||
|
{
|
||||||
|
"magFilter": 9729,
|
||||||
|
"minFilter": 9987
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"buffers": [
|
||||||
|
{
|
||||||
|
"byteLength": 758692,
|
||||||
|
"uri": "shrub_02.bin"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
assets/models/polyhaven/shrub_02/textures/shrub_02_arm_1k.jpg
Normal file
BIN
assets/models/polyhaven/shrub_02/textures/shrub_02_arm_1k.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 306 KiB |
BIN
assets/models/polyhaven/shrub_02/textures/shrub_02_diff_1k.jpg
Normal file
BIN
assets/models/polyhaven/shrub_02/textures/shrub_02_diff_1k.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 386 KiB |
BIN
assets/models/polyhaven/shrub_02/textures/shrub_02_nor_gl_1k.jpg
Normal file
BIN
assets/models/polyhaven/shrub_02/textures/shrub_02_nor_gl_1k.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 479 KiB |
@@ -250,6 +250,11 @@ def make_export_material(material):
|
|||||||
def unwrap_mesh(obj):
|
def unwrap_mesh(obj):
|
||||||
if obj.type != "MESH" or not obj.data.polygons:
|
if obj.type != "MESH" or not obj.data.polygons:
|
||||||
return
|
return
|
||||||
|
# Imported assets ship authored UVs that map onto their own texture atlas;
|
||||||
|
# smart_project would scramble the leaves. Only the procedurally built
|
||||||
|
# meshes arrive without a UV layer, so that is the reliable discriminator.
|
||||||
|
if obj.data.uv_layers:
|
||||||
|
return
|
||||||
bpy.ops.object.select_all(action="DESELECT")
|
bpy.ops.object.select_all(action="DESELECT")
|
||||||
obj.select_set(True)
|
obj.select_set(True)
|
||||||
bpy.context.view_layer.objects.active = obj
|
bpy.context.view_layer.objects.active = obj
|
||||||
@@ -281,6 +286,7 @@ def export(args):
|
|||||||
|
|
||||||
material_map = {}
|
material_map = {}
|
||||||
meshes = []
|
meshes = []
|
||||||
|
unwrapped = set()
|
||||||
for obj in bpy.context.scene.objects:
|
for obj in bpy.context.scene.objects:
|
||||||
if obj.type != "MESH":
|
if obj.type != "MESH":
|
||||||
continue
|
continue
|
||||||
@@ -290,7 +296,11 @@ def export(args):
|
|||||||
continue
|
continue
|
||||||
meshes.append(obj)
|
meshes.append(obj)
|
||||||
apply_mesh_modifiers(obj)
|
apply_mesh_modifiers(obj)
|
||||||
unwrap_mesh(obj)
|
# Hundreds of grass tufts share four mesh datablocks; unwrapping is a
|
||||||
|
# property of the mesh, so doing it once per datablock is enough.
|
||||||
|
if obj.data.name not in unwrapped:
|
||||||
|
unwrapped.add(obj.data.name)
|
||||||
|
unwrap_mesh(obj)
|
||||||
for slot in obj.material_slots:
|
for slot in obj.material_slots:
|
||||||
if not slot.material:
|
if not slot.material:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -34,6 +34,31 @@ TEXTURE_ROOT = os.path.abspath(os.path.join(
|
|||||||
os.path.dirname(__file__), "..", "assets", "textures", "polyhaven"
|
os.path.dirname(__file__), "..", "assets", "textures", "polyhaven"
|
||||||
))
|
))
|
||||||
|
|
||||||
|
MODEL_ROOT = os.path.abspath(os.path.join(
|
||||||
|
os.path.dirname(__file__), "..", "assets", "models", "polyhaven"
|
||||||
|
))
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
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.
|
||||||
|
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
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
def cli_args():
|
def cli_args():
|
||||||
values = {"osm": None, "geojson": None, "output": None, "render": None,
|
values = {"osm": None, "geojson": None, "output": None, "render": None,
|
||||||
@@ -162,7 +187,8 @@ def make_material(name, color, roughness=0.8, metallic=0.0):
|
|||||||
return material
|
return material
|
||||||
|
|
||||||
|
|
||||||
def add_procedural_surface(material, colors, scale=2.0, detail=2.0, bump_strength=0.08):
|
def add_procedural_surface(material, colors, scale=2.0, detail=2.0, bump_strength=0.08,
|
||||||
|
object_space=False):
|
||||||
nodes = material.node_tree.nodes
|
nodes = material.node_tree.nodes
|
||||||
links = material.node_tree.links
|
links = material.node_tree.links
|
||||||
bsdf = principled_bsdf(material)
|
bsdf = principled_bsdf(material)
|
||||||
@@ -179,13 +205,47 @@ def add_procedural_surface(material, colors, scale=2.0, detail=2.0, bump_strengt
|
|||||||
bump = nodes.new("ShaderNodeBump")
|
bump = nodes.new("ShaderNodeBump")
|
||||||
bump.inputs["Strength"].default_value = bump_strength
|
bump.inputs["Strength"].default_value = bump_strength
|
||||||
bump.inputs["Distance"].default_value = 0.12
|
bump.inputs["Distance"].default_value = 0.12
|
||||||
links.new(texcoord.outputs["Generated"], noise.inputs["Vector"])
|
# "Generated" normalises across the object bounding box, so on a mesh that
|
||||||
|
# spans the whole scene the noise stretches to tens of metres and vanishes.
|
||||||
|
# Object space keeps the scale in metres, which is what foliage needs.
|
||||||
|
source = "Object" if object_space else "Generated"
|
||||||
|
links.new(texcoord.outputs[source], noise.inputs["Vector"])
|
||||||
links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
|
links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
|
||||||
links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"])
|
links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"])
|
||||||
links.new(noise.outputs["Fac"], bump.inputs["Height"])
|
links.new(noise.outputs["Fac"], bump.inputs["Height"])
|
||||||
links.new(bump.outputs["Normal"], bsdf.inputs["Normal"])
|
links.new(bump.outputs["Normal"], bsdf.inputs["Normal"])
|
||||||
|
|
||||||
|
|
||||||
|
def tint_base_color(material, tint, factor):
|
||||||
|
"""Mix an existing material's base colour toward `tint`.
|
||||||
|
|
||||||
|
Imported assets arrive with their own diffuse texture wired up. Rather than
|
||||||
|
replacing it — which throws away the leaf detail — this splices a mix node
|
||||||
|
in front of the Base Color input so the texture survives at (1 - factor).
|
||||||
|
"""
|
||||||
|
if factor <= 0.0 or not material.use_nodes:
|
||||||
|
return
|
||||||
|
bsdf = principled_bsdf(material)
|
||||||
|
if not bsdf:
|
||||||
|
return
|
||||||
|
nodes = material.node_tree.nodes
|
||||||
|
links = material.node_tree.links
|
||||||
|
base = bsdf.inputs["Base Color"]
|
||||||
|
tint_node = nodes.new("ShaderNodeRGB")
|
||||||
|
tint_node.outputs["Color"].default_value = (*tint, 1.0)
|
||||||
|
mix = nodes.new("ShaderNodeMixRGB")
|
||||||
|
mix.blend_type = "MIX"
|
||||||
|
mix.inputs["Fac"].default_value = factor
|
||||||
|
if base.is_linked:
|
||||||
|
# Capture the upstream socket before relinking; Blender drops the old
|
||||||
|
# link as soon as the input takes a new one.
|
||||||
|
links.new(base.links[0].from_socket, mix.inputs[1])
|
||||||
|
else:
|
||||||
|
mix.inputs[1].default_value = base.default_value
|
||||||
|
links.new(tint_node.outputs["Color"], mix.inputs[2])
|
||||||
|
links.new(mix.outputs["Color"], base)
|
||||||
|
|
||||||
|
|
||||||
def make_textured_material(name, diffuse_file, normal_file, roughness,
|
def make_textured_material(name, diffuse_file, normal_file, roughness,
|
||||||
scale, normal_is_bump=False, metallic=0.0,
|
scale, normal_is_bump=False, metallic=0.0,
|
||||||
tint=None, tint_factor=0.0):
|
tint=None, tint_factor=0.0):
|
||||||
@@ -649,7 +709,146 @@ def point_in_polygon(point, ring):
|
|||||||
return inside
|
return inside
|
||||||
|
|
||||||
|
|
||||||
def add_scrub_patch(name, ring, material, collection):
|
def distance_to_ring(point, ring):
|
||||||
|
px, py = point
|
||||||
|
best = float("inf")
|
||||||
|
count = len(ring)
|
||||||
|
for index in range(count):
|
||||||
|
ax, ay = ring[index]
|
||||||
|
bx, by = ring[(index + 1) % count]
|
||||||
|
dx = bx - ax
|
||||||
|
dy = by - ay
|
||||||
|
length_sq = dx * dx + dy * dy
|
||||||
|
if length_sq <= 1e-9:
|
||||||
|
distance = math.hypot(px - ax, py - ay)
|
||||||
|
else:
|
||||||
|
t = ((px - ax) * dx + (py - ay) * dy) / length_sq
|
||||||
|
t = max(0.0, min(1.0, t))
|
||||||
|
distance = math.hypot(px - (ax + t * dx), py - (ay + t * dy))
|
||||||
|
if distance < best:
|
||||||
|
best = distance
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def load_tuft_variants():
|
||||||
|
"""Import the vendored Poly Haven plant once and return decimated 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)
|
||||||
|
return []
|
||||||
|
|
||||||
|
imported = [obj for obj in set(bpy.data.objects) - before if obj.type == "MESH"]
|
||||||
|
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 obj in imported:
|
||||||
|
bpy.data.objects.remove(obj, do_unlink=True)
|
||||||
|
|
||||||
|
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"
|
||||||
|
print(f"Grass tuft variants loaded: {len(variants)} ({detail}, {tris} tris shared)")
|
||||||
|
return variants
|
||||||
|
|
||||||
|
|
||||||
|
def tuft_density_wave(x, y):
|
||||||
|
# A lawn wants gentle clumping, not the hard banding a shrub bed needs:
|
||||||
|
# low amplitude keeps most of the polygon planted so the bare stretches
|
||||||
|
# read as mown patches rather than as dead ground.
|
||||||
|
return (math.sin(x * 0.21 + y * 0.17)
|
||||||
|
+ 0.55 * math.sin(x * 0.44 - y * 0.29 + 1.3))
|
||||||
|
|
||||||
|
|
||||||
|
def add_grass_tufts(name, ring, variants, collection):
|
||||||
|
"""Scatter the plant variants across a lawn polygon as grass tufts."""
|
||||||
|
xmin = min(x for x, _ in ring)
|
||||||
|
xmax = max(x for x, _ in ring)
|
||||||
|
ymin = min(y for _, y in ring)
|
||||||
|
ymax = max(y for _, y in ring)
|
||||||
|
|
||||||
|
def sample(spacing):
|
||||||
|
spots = []
|
||||||
|
cols = max(1, int(math.ceil((xmax - xmin) / spacing)))
|
||||||
|
rows = max(1, int(math.ceil((ymax - ymin) / spacing)))
|
||||||
|
for i in range(cols):
|
||||||
|
for j in range(rows):
|
||||||
|
# Jitter breaks up the lattice; without it the tufts read as a
|
||||||
|
# planted grid rather than as grass.
|
||||||
|
seed = (i * 73856093) ^ (j * 19349663)
|
||||||
|
jx = ((seed * 0.61803398875) % 1.0 - 0.5) * spacing * 0.8
|
||||||
|
jy = ((seed * 0.41421356237) % 1.0 - 0.5) * spacing * 0.8
|
||||||
|
x = xmin + (i + 0.5) * spacing + jx
|
||||||
|
y = ymin + (j + 0.5) * spacing + jy
|
||||||
|
if not point_in_polygon((x, y), ring):
|
||||||
|
continue
|
||||||
|
# Keep tufts off the kerb line so they do not overhang paving.
|
||||||
|
if distance_to_ring((x, y), ring) < 0.45:
|
||||||
|
continue
|
||||||
|
if tuft_density_wave(x, y) < -0.85:
|
||||||
|
continue
|
||||||
|
spots.append((x, y, seed))
|
||||||
|
return spots
|
||||||
|
|
||||||
|
# Adapt spacing to the lawn so a large polygon does not explode the export.
|
||||||
|
spacing = TUFT_SPACING
|
||||||
|
spots = sample(spacing)
|
||||||
|
while len(spots) > TUFT_LIMIT_PER_LAWN and spacing < 12.0:
|
||||||
|
spacing *= 1.22
|
||||||
|
spots = sample(spacing)
|
||||||
|
|
||||||
|
min_scale, max_scale = TUFT_SCALE_RANGE
|
||||||
|
span = max_scale - min_scale
|
||||||
|
for index, (x, y, seed) in enumerate(spots):
|
||||||
|
mesh = variants[index % len(variants)]
|
||||||
|
obj = bpy.data.objects.new(f"{name}_Tuft_{index:03d}", mesh)
|
||||||
|
scale = min_scale + span * ((seed * 0.754877666) % 1.0)
|
||||||
|
# Sunk slightly below the lawn surface so the stems never float.
|
||||||
|
obj.location = (x, y, 0.008)
|
||||||
|
obj.rotation_euler = (
|
||||||
|
math.radians(-4.0 + 8.0 * ((seed * 0.5698403) % 1.0)),
|
||||||
|
math.radians(-4.0 + 8.0 * ((seed * 0.3317554) % 1.0)),
|
||||||
|
math.tau * ((seed * 0.61803398875) % 1.0),
|
||||||
|
)
|
||||||
|
obj.scale = (scale, scale * (0.90 + 0.20 * ((seed * 0.2236068) % 1.0)), scale)
|
||||||
|
collection.objects.link(obj)
|
||||||
|
return len(spots)
|
||||||
|
|
||||||
|
|
||||||
|
def add_scrub_patch(name, ring, ground_material, collection):
|
||||||
if len(ring) < 3:
|
if len(ring) < 3:
|
||||||
return None
|
return None
|
||||||
if ring[0] == ring[-1]:
|
if ring[0] == ring[-1]:
|
||||||
@@ -657,63 +856,16 @@ def add_scrub_patch(name, ring, material, collection):
|
|||||||
if len(ring) < 3:
|
if len(ring) < 3:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
batch = MeshBatch(name, collection, material)
|
ground = MeshBatch(name, collection, ground_material)
|
||||||
batch.add_polygon(ring, 0.055)
|
ground.add_polygon(ring, 0.055)
|
||||||
|
obj = ground.finish()
|
||||||
|
|
||||||
xmin = min(x for x, _ in ring)
|
# The vendored plant asset reads as grass, so it went to the lawns. Until a
|
||||||
xmax = max(x for x, _ in ring)
|
# bush-shaped model lands, scrub beds stay flat ground cover: the previous
|
||||||
ymin = min(y for _, y in ring)
|
# procedural hedge mass read as blocky colour patches, not as planting.
|
||||||
ymax = max(y for _, y in ring)
|
|
||||||
width = max(0.1, xmax - xmin)
|
|
||||||
depth = max(0.1, ymax - ymin)
|
|
||||||
area = polygon_area(ring)
|
|
||||||
clump_count = max(10, min(90, int(area / 18.0) + 8))
|
|
||||||
sides = 10
|
|
||||||
rings = 4
|
|
||||||
|
|
||||||
def add_dome(cx, cy, rx, ry, height, phase):
|
|
||||||
start = len(batch.vertices)
|
|
||||||
for ring_index in range(rings):
|
|
||||||
t = ring_index / (rings - 1)
|
|
||||||
z = 0.055 + height * math.sin(t * math.pi / 2)
|
|
||||||
radius_scale = math.cos(t * math.pi / 2)
|
|
||||||
for side in range(sides):
|
|
||||||
angle = math.tau * side / sides
|
|
||||||
wobble = 1.0 + 0.12 * math.sin(phase + side * 1.37 + ring_index * 0.73)
|
|
||||||
batch.vertices.append((
|
|
||||||
cx + rx * radius_scale * math.cos(angle) * wobble,
|
|
||||||
cy + ry * radius_scale * math.sin(angle) * wobble,
|
|
||||||
z,
|
|
||||||
))
|
|
||||||
for ring_index in range(rings - 1):
|
|
||||||
for side in range(sides):
|
|
||||||
next_side = (side + 1) % sides
|
|
||||||
batch.faces.append((
|
|
||||||
start + ring_index * sides + side,
|
|
||||||
start + ring_index * sides + next_side,
|
|
||||||
start + (ring_index + 1) * sides + next_side,
|
|
||||||
start + (ring_index + 1) * sides + side,
|
|
||||||
))
|
|
||||||
|
|
||||||
added = 0
|
|
||||||
attempts = 0
|
|
||||||
while added < clump_count and attempts < clump_count * 8:
|
|
||||||
attempts += 1
|
|
||||||
u = (attempts * 0.61803398875) % 1.0
|
|
||||||
v = (attempts * 0.41421356237) % 1.0
|
|
||||||
x = xmin + u * width
|
|
||||||
y = ymin + v * depth
|
|
||||||
if not point_in_polygon((x, y), ring):
|
|
||||||
continue
|
|
||||||
scale = 0.65 + 0.55 * ((attempts * 0.754877666) % 1.0)
|
|
||||||
add_dome(x, y, 0.95 * scale, 0.72 * scale,
|
|
||||||
0.34 + 0.28 * scale, attempts * 0.91)
|
|
||||||
added += 1
|
|
||||||
|
|
||||||
obj = batch.finish()
|
|
||||||
if obj:
|
if obj:
|
||||||
obj["scrub_texture"] = "Poly Haven leafy_grass, scrub-tinted"
|
obj["scrub_texture"] = "Poly Haven leafy_grass, scrub-tinted"
|
||||||
obj["scrub_style"] = "tileable foliage ground cover with low shrub domes"
|
obj["scrub_style"] = "flat foliage ground cover"
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
@@ -830,8 +982,9 @@ def build(args):
|
|||||||
roughness=0.92, scale=7.0, tint=(0.12, 0.48, 0.08), tint_factor=0.72)
|
roughness=0.92, scale=7.0, tint=(0.12, 0.48, 0.08), tint_factor=0.72)
|
||||||
scrub_mat = make_textured_material(
|
scrub_mat = make_textured_material(
|
||||||
"Scrub Ground Cover", "leafy_grass_diff_1k.jpg",
|
"Scrub Ground Cover", "leafy_grass_diff_1k.jpg",
|
||||||
"leafy_grass_nor_gl_1k.jpg", roughness=0.96, scale=13.0,
|
"leafy_grass_nor_gl_1k.jpg", roughness=0.96, scale=15.0,
|
||||||
tint=(0.09, 0.32, 0.07), tint_factor=0.42)
|
tint=(0.085, 0.30, 0.065), tint_factor=0.46)
|
||||||
|
tuft_variants = load_tuft_variants()
|
||||||
fountain_mats = {
|
fountain_mats = {
|
||||||
"fountain_stone": make_material("Fountain Stone", (0.42, 0.45, 0.43), 0.72),
|
"fountain_stone": make_material("Fountain Stone", (0.42, 0.45, 0.43), 0.72),
|
||||||
"fountain_water": make_material("Fountain Water", (0.03, 0.32, 0.42), 0.16, 0.05),
|
"fountain_water": make_material("Fountain Water", (0.03, 0.32, 0.42), 0.16, 0.05),
|
||||||
@@ -888,6 +1041,7 @@ def build(args):
|
|||||||
tree_rows = []
|
tree_rows = []
|
||||||
lake_count = 0
|
lake_count = 0
|
||||||
grass_count = 0
|
grass_count = 0
|
||||||
|
grass_tuft_count = 0
|
||||||
scrub_count = 0
|
scrub_count = 0
|
||||||
fountain_count = 0
|
fountain_count = 0
|
||||||
building_count = 0
|
building_count = 0
|
||||||
@@ -915,8 +1069,14 @@ def build(args):
|
|||||||
batch = MeshBatch("Grass_" + str(way["id"]), green_c, grass_mat)
|
batch = MeshBatch("Grass_" + str(way["id"]), green_c, grass_mat)
|
||||||
if len(ring) >= 3:
|
if len(ring) >= 3:
|
||||||
batch.add_polygon(ring, 0.015)
|
batch.add_polygon(ring, 0.015)
|
||||||
batch.finish()
|
obj = batch.finish()
|
||||||
grass_count += 1
|
grass_count += 1
|
||||||
|
if tuft_variants:
|
||||||
|
tufts = add_grass_tufts("Grass_" + str(way["id"]), ring,
|
||||||
|
tuft_variants, green_c)
|
||||||
|
grass_tuft_count += tufts
|
||||||
|
if obj:
|
||||||
|
obj["grass_tufts"] = tufts
|
||||||
elif tag.get("natural") == "scrub" and len(ring) >= 3:
|
elif tag.get("natural") == "scrub" and len(ring) >= 3:
|
||||||
ring = clip_polygon(ring, scene_xmin, scene_xmax,
|
ring = clip_polygon(ring, scene_xmin, scene_xmax,
|
||||||
scene_ymin, scene_ymax)
|
scene_ymin, scene_ymax)
|
||||||
@@ -1079,6 +1239,7 @@ def build(args):
|
|||||||
scene["office_override_way_ids"] = json.dumps(sorted(args["office_overrides"]))
|
scene["office_override_way_ids"] = json.dumps(sorted(args["office_overrides"]))
|
||||||
scene["lake_count"] = lake_count
|
scene["lake_count"] = lake_count
|
||||||
scene["grass_count"] = grass_count
|
scene["grass_count"] = grass_count
|
||||||
|
scene["grass_tuft_count"] = grass_tuft_count
|
||||||
scene["scrub_count"] = scrub_count
|
scene["scrub_count"] = scrub_count
|
||||||
scene["fountain_count"] = fountain_count
|
scene["fountain_count"] = fountain_count
|
||||||
scene["tree_node_count"] = individual_tree_count
|
scene["tree_node_count"] = individual_tree_count
|
||||||
@@ -1097,6 +1258,7 @@ def build(args):
|
|||||||
"industrial_buildings": industrial_count,
|
"industrial_buildings": industrial_count,
|
||||||
"lake": lake_count,
|
"lake": lake_count,
|
||||||
"grass": grass_count,
|
"grass": grass_count,
|
||||||
|
"grass_tufts": grass_tuft_count,
|
||||||
"scrub": scrub_count,
|
"scrub": scrub_count,
|
||||||
"fountains": fountain_count,
|
"fountains": fountain_count,
|
||||||
"tree_nodes": individual_tree_count,
|
"tree_nodes": individual_tree_count,
|
||||||
|
|||||||
Reference in New Issue
Block a user