Skip to content

Commit 71b4f2b

Browse files
TMHSDigitalclaude
andcommitted
fix(showcase): dress terrain-scatter with grass cover, tufts and pebbles
The tile read as a brown slab with a few grey rocks. On the render path, the dirt shader now blends in grass where the ground faces up (geometry normal Z), broken by a coarse patch noise, so the rim and the cut sides stay topsoil over subsoil. A render-only dressing drawn with a fixed seed from the up-facing dirt faces adds 120 clumped grass tufts and 40 sunk pebbles in the stone material. Geometry, budgets and falsifiers are unchanged. The dressing is staging, parented to the tile, and the asset sheet judges TerrainLow alone. Evidence (live runs, local): - E:/Blender-Developer-Tools/.scratch/blender-5.2.1-windows-x64/blender.exe (Blender 5.2.1 LTS) and .../blender-4.5.11-windows-x64/blender.exe (Blender 4.5.11 LTS): default exit 0 on both. Falsifiers on both: --skip-decimate 9, --stray-vert 15, --lift-z 16, --poke-rock 17, --float-rocks 18, --box-rocks 19, --pile-rocks 20, --perch-rocks 21, --uniform-rocks 22, --sharp-rim 23, all as documented. - --output renders on both versions. Framing (5.2.1): fill x=0.797 y=0.744, margins l=0.134 r=0.069 b=0.117 t=0.139. - Contact sheet: candidate luma 0.194 / stage 0.198 / warmth +0.122 / sat 0.147, against armature-bend 0.243/0.192/-0.144/0.237, damped-track-aim 0.237/0.218/+0.105/0.178, and bmesh-gear 0.236/0.177/+0.303/0.250. Stage and warmth are in band; luma runs a little darker. - Asset sheet (docs/gallery/asset-sheets/terrain-scatter.webp): the grassed tile with its sunk stones is not the least-designed object in the lineup. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent e81385b commit 71b4f2b

8 files changed

Lines changed: 140 additions & 2 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show
116116
<a href="showcase/market-stall/"><img src="showcase/market-stall/preview.webp" width="24%" alt="Market stall: a grained timber frame with post feet, diagonal side braces, slatted counter, rafters, and a striped canvas awning on a dark studio floor, warm wedge on the back wall" /></a>
117117
<a href="showcase/street-lantern/"><img src="showcase/street-lantern/preview.webp" width="24%" alt="Street lantern: an iron post with aged brass collars, a knee brace tenoned into post and arm, and a muntined amber cage on a dark studio floor" /></a>
118118
<a href="showcase/treasure-chest/"><img src="showcase/treasure-chest/preview.webp" width="24%" alt="Treasure chest: a slatted wooden chest with iron bands, corner brackets, and an open lid on a dark studio floor" /></a>
119-
<a href="showcase/terrain-scatter/"><img src="showcase/terrain-scatter/preview.webp" width="24%" alt="Terrain scatter: a Geometry Nodes dirt hill tile with bevelled masonry rocks on a dark studio floor" /></a>
119+
<a href="showcase/terrain-scatter/"><img src="showcase/terrain-scatter/preview.webp" width="24%" alt="Terrain scatter: a Geometry Nodes hill tile of patchy grass over dark earth, nine faceted stones sunk into it among grass tufts and pebbles, on a dark studio floor" /></a>
120120
<a href="showcase/fence-kit/"><img src="showcase/fence-kit/preview.webp" width="24%" alt="Fence kit: a run of three weathered post-and-rail sections on a packed-earth strip, joined post to post at the 1.60 m tile pitch, with pyramidal caps, diagonal board braces, and iron bands" /></a>
121121
<a href="showcase/watchtower/"><img src="showcase/watchtower/preview.webp" width="24%" alt="Watchtower: a grained timber lookout with X-braces, hatch ladder, a plank platform on deck girts, and a coursed shake roof on a dark studio floor" /></a>
122122
<a href="showcase/cart/"><img src="showcase/cart/preview.webp" width="24%" alt="Cart: a two-wheel wooden cart with spoked wheels, slatted bed, and shafts on a dark studio floor" /></a>
16.6 KB
Loading
9.31 KB
Loading
114 Bytes
Loading

‎showcase/gallery.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@
102102
"name": "terrain-scatter",
103103
"dir": "showcase/terrain-scatter",
104104
"teaches": "A Geometry Nodes hill tile with embedded, varied stone scatter through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.",
105-
"alt": "A square tile of dark rolling earth with a rounded edge, nine faceted grey stones from cobble to boulder sunk into the ground across the hill.",
105+
"alt": "A square hill tile of patchy grass over dark earth with a rounded soil edge, nine faceted grey stones sunk across it among grass tufts and pebbles.",
106106
"witnessesFix": "Recomputed: 2398 tris, two materials with 720 stone faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.800x1.800x0.570 m, grounded zmin, 9 stones of >=80 faces each sealed into the ground in 8/8 sectors, footprint ratio 9.8, rim tilt step 33.5 deg, no stone pairs overlapping, LOD ratios in band, convex collider 36 tris, hygiene zero, non-empty glTF. --skip-decimate exits 9; --stray-vert 15; --lift-z 16; --poke-rock 17; --float-rocks 18; --box-rocks 19; --pile-rocks 20; --perch-rocks 21; --uniform-rocks 22; --sharp-rim 23.",
107107
"hero": "docs/gallery/assets/terrain-scatter-hero.webp",
108108
"preview": "showcase/terrain-scatter/preview.webp",

‎showcase/terrain-scatter/README.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ Intended size: 1.80 m square hill tile, ~0.16 m sine amplitude, nine
2727
embedded stones from cobble to boulder; outer AABB 1.800 × 1.800 ×
2828
0.570 m.
2929

30+
**Hero.** On the render path, grass is blended into the dirt shader where
31+
the ground faces up (geometry normal Z), broken by a coarse patch noise so
32+
bare soil shows through. The rounded rim and the cut sides tip past the
33+
band, so the section still reads as topsoil over subsoil. A render-only
34+
dressing, drawn with a fixed seed from the up-facing dirt faces, adds 120
35+
clumped grass tufts and 40 sunk pebbles in the stone material. The dressing
36+
is staging, parented to the tile, and is not part of the asset, its budgets
37+
or its export. The asset sheet judges `TerrainLow` alone.
38+
3039
## Budgets
3140

3241
Declared as named constants; every gate **recomputes** from the mesh,
8.09 KB
Loading

‎showcase/terrain-scatter/terrain_scatter.py‎

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import argparse
3232
import math
3333
import os
34+
import random
3435
import sys
3536
import tempfile
3637
import traceback
@@ -1424,16 +1425,144 @@ def wire_normal(mat, tex):
14241425
nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"])
14251426

14261427

1428+
def grass_over(dirt):
1429+
"""Render path: patchy grass on the up-facing ground, soil on the cut.
1430+
1431+
Grass holds where the surface faces up (geometry normal Z) and a coarse
1432+
patch noise lets bare soil through; the rounded rim and the slab sides
1433+
tip past the band, so the cut still reads as topsoil over subsoil.
1434+
"""
1435+
nt = dirt.node_tree
1436+
bsdf = nt.nodes["Principled BSDF"]
1437+
link = bsdf.inputs["Base Color"].links[0]
1438+
soil = link.from_socket
1439+
nt.links.remove(link)
1440+
coord = nt.nodes.new("ShaderNodeTexCoord")
1441+
geo = nt.nodes.new("ShaderNodeNewGeometry")
1442+
sep = nt.nodes.new("ShaderNodeSeparateXYZ")
1443+
nt.links.new(geo.outputs["Normal"], sep.inputs[0])
1444+
up = nt.nodes.new("ShaderNodeMapRange")
1445+
up.inputs["From Min"].default_value = 0.80
1446+
up.inputs["From Max"].default_value = 0.95
1447+
nt.links.new(sep.outputs["Z"], up.inputs["Value"])
1448+
patch = nt.nodes.new("ShaderNodeTexNoise")
1449+
patch.inputs["Scale"].default_value = 3.2
1450+
patch.inputs["Detail"].default_value = 6.0
1451+
nt.links.new(coord.outputs["Object"], patch.inputs["Vector"])
1452+
cover = nt.nodes.new("ShaderNodeMapRange")
1453+
cover.inputs["From Min"].default_value = 0.40
1454+
cover.inputs["From Max"].default_value = 0.55
1455+
nt.links.new(patch.outputs["Fac"], cover.inputs["Value"])
1456+
amount = nt.nodes.new("ShaderNodeMath")
1457+
amount.operation = "MULTIPLY"
1458+
nt.links.new(up.outputs["Result"], amount.inputs[0])
1459+
nt.links.new(cover.outputs["Result"], amount.inputs[1])
1460+
blades = nt.nodes.new("ShaderNodeTexNoise")
1461+
blades.inputs["Scale"].default_value = 140.0
1462+
blades.inputs["Detail"].default_value = 2.0
1463+
nt.links.new(coord.outputs["Object"], blades.inputs["Vector"])
1464+
green = nt.nodes.new("ShaderNodeValToRGB")
1465+
green.color_ramp.elements[0].color = (0.030, 0.045, 0.012, 1.0)
1466+
green.color_ramp.elements[1].color = (0.105, 0.130, 0.035, 1.0)
1467+
nt.links.new(blades.outputs["Fac"], green.inputs["Fac"])
1468+
mix = nt.nodes.new("ShaderNodeMix")
1469+
mix.data_type = "RGBA"
1470+
nt.links.new(amount.outputs["Value"], _sock(mix.inputs, "Factor_Float"))
1471+
nt.links.new(soil, _sock(mix.inputs, "A_Color"))
1472+
nt.links.new(green.outputs["Color"], _sock(mix.inputs, "B_Color"))
1473+
nt.links.new(_sock(mix.outputs, "Result_Color"), bsdf.inputs["Base Color"])
1474+
1475+
1476+
def ground_dressing(low, stone_mat, seed=11, n_tufts=120, n_pebbles=40):
1477+
"""Render-only tufts and pebbles on the tile's up-facing dirt faces.
1478+
1479+
Sites are dirt faces (material DIRT_IDX) facing up, drawn by area with
1480+
a fixed seed, so the dressing is the same every run. Pebbles sink a
1481+
third of their height; tufts are a few tapered blades each. Returned
1482+
objects are staging, parented to the tile, never part of its budgets.
1483+
"""
1484+
me = low.data
1485+
rng = random.Random(seed)
1486+
sites = [p for p in me.polygons
1487+
if p.material_index == DIRT_IDX and p.normal.z > 0.9]
1488+
weights = [p.area for p in sites]
1489+
1490+
def site():
1491+
p = rng.choices(sites, weights)[0]
1492+
vs = [me.vertices[i].co for i in p.vertices]
1493+
w = [rng.random() for _ in vs]
1494+
s = sum(w)
1495+
return sum((v * (wi / s) for v, wi in zip(vs, w)), Vector()), p.normal
1496+
1497+
bm = bmesh.new()
1498+
try:
1499+
for _ in range(n_tufts):
1500+
base, _nrm = site()
1501+
for _b in range(rng.randint(9, 15)):
1502+
a = rng.uniform(0.0, 2 * math.pi)
1503+
h = rng.uniform(0.028, 0.062)
1504+
lean = rng.uniform(0.15, 0.55)
1505+
w = rng.uniform(0.004, 0.007)
1506+
d = Vector((math.cos(a), math.sin(a), 0.0))
1507+
side = Vector((-d.y, d.x, 0.0)) * w
1508+
base_b = base + Vector((rng.uniform(-0.012, 0.012), rng.uniform(-0.012, 0.012), 0.0))
1509+
root = base_b - Vector((0, 0, 0.004))
1510+
tip = base_b + d * (h * lean) + Vector((0.0, 0.0, h))
1511+
mid = base_b + d * (h * lean * 0.35) + Vector((0.0, 0.0, h * 0.55))
1512+
vs = [bm.verts.new(root - side), bm.verts.new(root + side),
1513+
bm.verts.new(mid + side * 0.6), bm.verts.new(mid - side * 0.6),
1514+
bm.verts.new(tip)]
1515+
bm.faces.new(vs[:4])
1516+
bm.faces.new((vs[3], vs[2], vs[4]))
1517+
tme = bpy.data.meshes.new("GrassTufts")
1518+
bm.to_mesh(tme)
1519+
finally:
1520+
bm.free()
1521+
gmat = bpy.data.materials.new("Tuft")
1522+
gmat.use_nodes = True
1523+
gb = gmat.node_tree.nodes["Principled BSDF"]
1524+
gb.inputs["Base Color"].default_value = (0.090, 0.118, 0.030, 1.0)
1525+
gb.inputs["Roughness"].default_value = 0.75
1526+
tme.materials.append(gmat)
1527+
tufts = bpy.data.objects.new("GrassTufts", tme)
1528+
1529+
bm = bmesh.new()
1530+
try:
1531+
for _ in range(n_pebbles):
1532+
base, _nrm = site()
1533+
r = rng.uniform(0.010, 0.026)
1534+
geo = bmesh.ops.create_icosphere(bm, subdivisions=1, radius=1.0)
1535+
sx, sy, sz = r * rng.uniform(0.8, 1.3), r * rng.uniform(0.8, 1.2), r * rng.uniform(0.45, 0.7)
1536+
rot = rng.uniform(0.0, 2 * math.pi)
1537+
c, s = math.cos(rot), math.sin(rot)
1538+
for v in geo["verts"]:
1539+
x, y, z = v.co.x * sx, v.co.y * sy, v.co.z * sz
1540+
v.co = Vector((c * x - s * y, s * x + c * y, z)) + base - Vector((0, 0, sz * 0.35))
1541+
pme = bpy.data.meshes.new("Pebbles")
1542+
bm.to_mesh(pme)
1543+
finally:
1544+
bm.free()
1545+
pme.materials.append(stone_mat)
1546+
pebbles = bpy.data.objects.new("Pebbles", pme)
1547+
for ob in (tufts, pebbles):
1548+
ob.parent = low
1549+
return [tufts, pebbles]
1550+
1551+
14271552
def render_still(low, dirt, tex, path, engine):
14281553
scene = bpy.context.scene
14291554
wire_normal(dirt, tex)
1555+
grass_over(dirt)
14301556
for ob in list(scene.objects):
14311557
if ob.type == "MESH" and ob != low:
14321558
ob.hide_render = True
14331559
ob.hide_viewport = True
14341560

14351561
low.rotation_euler.z = math.radians(-28.0)
14361562
low.rotation_euler.x = math.radians(0.0)
1563+
dressing = ground_dressing(low, low.data.materials[STONE_IDX])
1564+
for ob in dressing:
1565+
scene.collection.objects.link(ob)
14371566

14381567
floor_me = bpy.data.meshes.new("Floor")
14391568
bm = bmesh.new()

0 commit comments

Comments
 (0)