This commit is contained in:
Gabrieal Jimmy
2026-10-02 12:42:00 -05:00
commit 2c99305fb0
145 changed files with 6525 additions and 0 deletions

78
tools/assets.json Normal file
View File

@@ -0,0 +1,78 @@
{
"ground_grass": {
"kind": "tiles",
"tile_id": "d69441f7-e60f-4573-bc79-3f32f6594a2f",
"out": "ground_grass.png",
"pick": 0,
"description": "1). lush green meadow grass, dense short blades 2). lush green meadow grass with a few tiny white flowers 3). lush green meadow grass with clover 4). lush green meadow grass, slightly darker",
"tile_type": "square_topdown",
"tile_size": 32,
"tile_view": "top-down",
"outline_mode": "segmentation",
"seed": 11
},
"ground_misc": {
"kind": "tiles",
"out": "ground_misc.png",
"tile_id": "a029d543-909a-49ff-a7f2-755cbe44e9fb",
"pick": 0,
"description": "1). packed brown dirt path with small pebbles 2). mossy grey stone cliff wall with cracks 3). wet sand and gravel riverbed 4). grey cobblestone",
"tile_type": "square_topdown",
"tile_size": 32,
"tile_view": "top-down",
"outline_mode": "segmentation",
"seed": 12
},
"ground_extra": {
"kind": "tiles",
"tile_id": "1f1fc7e2-bed9-4fff-9dad-da2e69cd6ba2",
"out": "ground_extra.png",
"pick": 0,
"description": "1). smooth packed earth footpath, warm brown soil with scattered tiny pebbles, no stripes 2). earthen cliff face, layered brown soil and embedded stones 3). fine pale sand riverbed with small pebbles 4). dark forest floor soil with moss",
"tile_type": "square_topdown",
"tile_size": 32,
"tile_view": "top-down",
"outline_mode": "segmentation",
"seed": 13
},
"tall_grass": {
"out": "tall_grass.png",
"description": "clump of tall wild grass, long pointed dark green and light green blades, pokemon style tall grass patch, game sprite",
"image_size": {
"width": 64,
"height": 64
},
"no_background": true,
"view": "side",
"outline": "selective outline",
"shading": "detailed shading",
"detail": "highly detailed",
"seed": 21
},
"grass_tuft": {
"out": "grass_tuft.png",
"description": "small tuft of short green grass blades, game sprite",
"image_size": {
"width": 32,
"height": 32
},
"no_background": true,
"view": "side",
"outline": "lineless",
"shading": "medium shading",
"seed": 22
},
"flowers": {
"out": "flowers.png",
"description": "small cluster of wildflowers with white and yellow petals on green stems, game sprite",
"image_size": {
"width": 32,
"height": 32
},
"no_background": true,
"view": "side",
"outline": "selective outline",
"shading": "medium shading",
"seed": 23
}
}

527
tools/blender_scenery.py Normal file
View File

@@ -0,0 +1,527 @@
"""Builds the HD-2D scenery models (trees, bushes, rocks) procedurally in
Blender and exports one .glb per model into assets/models/.
blender -b -P tools/blender_scenery.py # headless
# or, inside a running Blender (e.g. via the Blender MCP):
exec(open("<repo>/tools/blender_scenery.py").read())
Textures come from tools/scenery_textures.py (assets/models/src/*.png) and are
embedded into the .glb files.
Conventions the engine relies on (see engine/src/Model.cpp):
* COLOR_0.rgb linear tint / baked ambient occlusion (multiplies the texture)
* COLOR_0.a wind weight (0 = rigid, 1 = fully swaying foliage)
* materials named "foliage*" are alpha-tested, double-sided and translucent
* custom normals on foliage point away from the canopy centre, which gives
the soft, rounded shading of stylized trees instead of flat cards
"""
import math
import os
import random
import bpy
import mathutils
from mathutils import Vector
REPO = os.environ.get("HD2D_REPO") or os.path.dirname(os.path.dirname(os.path.abspath(
__file__ if "__file__" in globals() else "/home/gabe/Documents/hd_2d_engine/tools/x")))
SRC = os.path.join(REPO, "assets", "models", "src")
OUT = os.path.join(REPO, "assets", "models")
# ----------------------------------------------------------------- builder --
class MeshBuilder:
"""Accumulates faces with per-corner uv / color / normal and material ids."""
def __init__(self):
self.verts, self.faces, self.uvs, self.cols, self.nrms, self.mats = [], [], [], [], [], []
def face(self, pts, uvs, cols, nrms, mat):
base = len(self.verts)
self.verts.extend(pts)
self.faces.append(list(range(base, base + len(pts))))
self.uvs.extend(uvs)
self.cols.extend(cols)
self.nrms.extend(nrms)
self.mats.append(mat)
def build(self, name, materials, smooth=True):
me = bpy.data.meshes.new(name)
me.from_pydata([tuple(v) for v in self.verts], [], self.faces)
for m in materials:
me.materials.append(m)
me.polygons.foreach_set("material_index", self.mats)
me.polygons.foreach_set("use_smooth", [smooth] * len(self.faces))
uv = me.uv_layers.new(name="UVMap")
col = me.color_attributes.new("Col", "FLOAT_COLOR", "CORNER")
for li, loop in enumerate(me.loops): # from_pydata keeps corner order
uv.data[li].uv = self.uvs[li]
col.data[li].color = self.cols[li]
me.color_attributes.active_color = col
me.validate()
me.normals_split_custom_set([tuple(n) for n in self.nrms])
ob = bpy.data.objects.new(name, me)
COLLECTION.objects.link(ob)
return ob
def material(name, image, alpha=False, tint_from_vcol=True, emission=0.0):
mat = bpy.data.materials.get(name) or bpy.data.materials.new(name)
mat.use_nodes = True
nt = mat.node_tree
nt.nodes.clear()
out = nt.nodes.new("ShaderNodeOutputMaterial")
bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
bsdf.inputs["Roughness"].default_value = 0.85
tex = nt.nodes.new("ShaderNodeTexImage")
path = os.path.join(SRC, image)
tex.image = bpy.data.images.load(path, check_existing=True)
color = tex.outputs["Color"]
if tint_from_vcol:
vc = nt.nodes.new("ShaderNodeVertexColor")
vc.layer_name = "Col"
mul = nt.nodes.new("ShaderNodeMix")
mul.data_type = "RGBA"
mul.blend_type = "MULTIPLY"
mul.inputs["Factor"].default_value = 1.0
nt.links.new(color, mul.inputs["A"])
nt.links.new(vc.outputs["Color"], mul.inputs["B"])
color = mul.outputs["Result"]
nt.links.new(color, bsdf.inputs["Base Color"])
if alpha:
cut = nt.nodes.new("ShaderNodeMath") # Round -> exported as alphaMode MASK
cut.operation = "ROUND"
nt.links.new(tex.outputs["Alpha"], cut.inputs[0])
nt.links.new(cut.outputs[0], bsdf.inputs["Alpha"])
mat.surface_render_method = "DITHERED"
if emission > 0.0: # exported as emissiveTexture + KHR_materials_emissive_strength
nt.links.new(tex.outputs["Color"], bsdf.inputs["Emission Color"])
bsdf.inputs["Emission Strength"].default_value = emission
mat.use_backface_culling = not alpha # exported as glTF doubleSided
nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
return mat
def frame(t):
t = t.normalized()
up = Vector((0, 0, 1)) if abs(t.z) < 0.95 else Vector((1, 0, 0))
a = t.cross(up).normalized()
return a, t.cross(a).normalized()
def tube(mb, pts, radii, mat, sides=10, ao=(0.6, 1.0), wind=lambda p: 0.0, texel=1.2):
"""Tapered tube along a polyline, with cylindrical bark UVs."""
rings, length = [], 0.0
circ = 2 * math.pi * sum(radii) / len(radii)
urep = max(1, round(circ / 0.7))
for i, p in enumerate(pts):
t = (pts[min(i + 1, len(pts) - 1)] - pts[max(i - 1, 0)])
a, b = frame(t)
if i:
length += (p - pts[i - 1]).length
ring = []
for j in range(sides + 1):
ang = j / sides * math.tau
n = a * math.cos(ang) + b * math.sin(ang)
ring.append((p + n * radii[i], n, (j / sides * urep, length / texel)))
rings.append(ring)
for i in range(len(rings) - 1):
k0 = ao[0] + (ao[1] - ao[0]) * min(1.0, i / max(1, len(rings) - 2))
k1 = ao[0] + (ao[1] - ao[0]) * min(1.0, (i + 1) / max(1, len(rings) - 2))
for j in range(sides):
quad = [rings[i][j], rings[i][j + 1], rings[i + 1][j + 1], rings[i + 1][j]]
ks = [k0, k0, k1, k1]
mb.face([q[0] for q in quad], [q[2] for q in quad],
[(k, k, k, wind(q[0])) for q, k in zip(quad, ks)], [q[1] for q in quad], mat)
def ico(subdiv):
"""Unit icosphere vertices + triangles."""
t = (1 + 5 ** 0.5) / 2
v = [Vector(x).normalized() for x in [(-1, t, 0), (1, t, 0), (-1, -t, 0), (1, -t, 0), (0, -1, t), (0, 1, t),
(0, -1, -t), (0, 1, -t), (t, 0, -1), (t, 0, 1), (-t, 0, -1), (-t, 0, 1)]]
f = [(0, 11, 5), (0, 5, 1), (0, 1, 7), (0, 7, 10), (0, 10, 11), (1, 5, 9), (5, 11, 4), (11, 10, 2), (10, 7, 6),
(7, 1, 8), (3, 9, 4), (3, 4, 2), (3, 2, 6), (3, 6, 8), (3, 8, 9), (4, 9, 5), (2, 4, 11), (6, 2, 10),
(8, 6, 7), (9, 8, 1)]
for _ in range(subdiv):
cache, nf = {}, []
def mid(a, b):
key = (min(a, b), max(a, b))
if key not in cache:
cache[key] = len(v)
v.append(((v[a] + v[b]) / 2).normalized())
return cache[key]
for a, b, c in f:
ab, bc, ca = mid(a, b), mid(b, c), mid(c, a)
nf += [(a, ab, ca), (b, bc, ab), (c, ca, bc), (ab, bc, ca)]
f = nf
return v, f
def blend_normal(p, centre, local):
return ((p - centre).normalized() * 0.65 + local * 0.35).normalized()
def clump(mb, rng, c, r, canopy_c, canopy_r, mat_core, mat_cards, cards_per_r2=42, card_scale=1.0,
wind=1.0, ao_floor=0.45, core=True):
"""One leafy lump: an opaque leaf-mass core plus alpha-tested leaf cards."""
def ao_at(p):
h = (p.z - (canopy_c.z - canopy_r)) / (2 * canopy_r)
return ao_floor + (1 - ao_floor) * max(0.0, min(1.0, h)) ** 0.8
if core:
v, f = ico(2)
for tri in f:
ps = [c + v[i] * r * 0.8 for i in tri]
uvs = [(math.atan2(v[i].y, v[i].x) / math.pi * 1.5, v[i].z * 1.5) for i in tri]
if max(u for u, _ in uvs) - min(u for u, _ in uvs) > 1.5: # fix the atan2 seam
uvs = [(u + 3.0 if u < 0 else u, w) for u, w in uvs]
cols = [(ao_at(p) * 0.75,) * 3 + (wind,) for p in ps]
mb.face(ps, uvs, cols, [blend_normal(p, canopy_c, v[i]) for p, i in zip(ps, tri)], mat_core)
n = int(cards_per_r2 * r * r) + 6
for _ in range(n):
d = Vector((rng.gauss(0, 1), rng.gauss(0, 1), rng.gauss(0, 1) * 0.8 + 0.35)).normalized()
p = c + d * r * rng.uniform(0.72, 1.0)
cn = (d + Vector((rng.gauss(0, 0.5), rng.gauss(0, 0.5), rng.gauss(0, 0.5)))).normalized()
a, b = frame(cn)
roll = rng.random() * math.tau
a, b = a * math.cos(roll) + b * math.sin(roll), b * math.cos(roll) - a * math.sin(roll)
s = r * rng.uniform(0.85, 1.2) * card_scale
corners = [p - a * s / 2 - b * s / 2, p + a * s / 2 - b * s / 2, p + a * s / 2 + b * s / 2, p - a * s / 2 + b * s / 2]
tint = rng.uniform(0.88, 1.1)
mb.face(corners, [(0, 0), (1, 0), (1, 1), (0, 1)],
[(ao_at(q) * tint,) * 3 + (wind,) for q in corners], [blend_normal(q, canopy_c, d) for q in corners],
mat_cards)
# ------------------------------------------------------------------ models --
def make_oak(name, seed):
rng = random.Random(seed)
mb = MeshBuilder()
trunk_h = rng.uniform(2.4, 2.9)
lean = Vector((rng.uniform(-0.25, 0.25), rng.uniform(-0.25, 0.25), 0))
pts = [Vector((0, 0, -0.3)), Vector((0, 0, 0.0)), Vector((0, 0, 0.25))]
pts += [Vector((0, 0, z)) + lean * (z / trunk_h) ** 2 for z in (0.8, 1.4, 2.0, trunk_h)]
radii = [0.52, 0.42, 0.33, 0.28, 0.26, 0.24, 0.2]
wind_trunk = lambda p: max(0.0, min(1.0, (p.z - 1.2) / 4.0)) * 0.25
tube(mb, pts, radii, 0, ao=(0.45, 1.0), wind=wind_trunk)
top = pts[-1]
canopy_c = top + Vector((0, 0, 1.25))
canopy_r = 2.2
clumps = [(canopy_c, 1.45), (canopy_c + Vector((0, 0, 0.95)), 1.05)]
n = rng.randint(5, 7)
for k in range(n):
ang = k / n * math.tau + rng.uniform(-0.3, 0.3)
dist = rng.uniform(1.05, 1.45)
c = canopy_c + Vector((math.cos(ang) * dist, math.sin(ang) * dist, rng.uniform(-0.65, 0.35)))
clumps.append((c, rng.uniform(0.85, 1.15)))
# A branch from the trunk top toward each outer clump.
start = top + Vector((0, 0, rng.uniform(-0.6, 0.0)))
mid = start.lerp(c, 0.5) + Vector((0, 0, 0.25))
tube(mb, [start, mid, start.lerp(c, 0.85)], [0.14, 0.09, 0.05], 0, sides=6, ao=(0.7, 0.9), wind=wind_trunk)
for c, r in clumps:
clump(mb, rng, c, r, canopy_c, canopy_r, 1, 2)
return mb.build(name, [MAT["bark"], MAT["foliage_core"], MAT["foliage_oak"]])
def make_pine(name, seed):
rng = random.Random(seed)
mb = MeshBuilder()
height = rng.uniform(5.8, 6.6)
pts = [Vector((0, 0, z)) for z in (-0.3, 0.3, 1.5, 3.0, 4.5, height)]
radii = [0.3, 0.22, 0.18, 0.13, 0.08, 0.02]
tube(mb, pts, radii, 0, sides=8, ao=(0.5, 0.9), wind=lambda p: max(0.0, (p.z - 1.0) / height) * 0.3)
# Dense core cone so gaps between fronds read as deep needles, not sky.
sides, tiers = 12, 8
z0, z1 = 0.9, height - 0.1
for i in range(tiers):
za, zb = z0 + (z1 - z0) * i / tiers, z0 + (z1 - z0) * (i + 1) / tiers
ra, rb = 1.15 * (1 - i / tiers) + 0.05, 1.15 * (1 - (i + 1) / tiers) + 0.05
for j in range(sides):
a0, a1 = j / sides * math.tau, (j + 1) / sides * math.tau
quad = [Vector((math.cos(a0) * ra, math.sin(a0) * ra, za)), Vector((math.cos(a1) * ra, math.sin(a1) * ra, za)),
Vector((math.cos(a1) * rb, math.sin(a1) * rb, zb)), Vector((math.cos(a0) * rb, math.sin(a0) * rb, zb))]
uvs = [(j / sides * 3, za / 1.5), ((j + 1) / sides * 3, za / 1.5), ((j + 1) / sides * 3, zb / 1.5), (j / sides * 3, zb / 1.5)]
nrm = [Vector((q.x, q.y, 0.6)).normalized() for q in quad]
k = [0.5 + 0.4 * (q.z / height) for q in quad]
mb.face(quad, uvs, [(kk * 0.6, kk * 0.9, kk * 0.8, 0.5) for kk in k], nrm, 1)
h = 0.85
tier = 0
while h < height - 0.3:
frac = (h - 0.85) / (height - 1.1)
length = 2.5 * (1 - frac) ** 0.95 + 0.45
count = 11 if frac < 0.7 else 7
for k in range(count):
ang = k / count * math.tau + tier * 0.55 + rng.uniform(-0.2, 0.2)
out = Vector((math.cos(ang), math.sin(ang), 0))
side = Vector((-out.y, out.x, 0))
roll = rng.uniform(-0.35, 0.35)
across = (side * math.cos(roll) + Vector((0, 0, 1)) * math.sin(roll)) * length * 0.32
base = Vector((0, 0, h + rng.uniform(-0.08, 0.08)))
droop = rng.uniform(0.25, 0.4)
segs = [base + out * length * t + Vector((0, 0, length * (0.12 * t - droop * t * t))) for t in (0, 0.34, 0.67, 1.0)]
# Flat card seen from above + a narrower vertical cross card for volume from the side.
cross = (out.cross(across).normalized()) * length * 0.2
for spread, kc in ((across, 1.0), (cross, 0.85)):
for s in range(3):
t0, t1 = s / 3, (s + 1) / 3
quad = [segs[s] - spread, segs[s + 1] - spread, segs[s + 1] + spread, segs[s] + spread]
uvs = [(t0, 0), (t1, 0), (t1, 1), (t0, 1)]
up = Vector((0, 0, 1))
nrm = [(out * 0.45 + up * 0.9).normalized()] * 4
ks = [(0.55 + 0.45 * t) * kc for t in (t0, t1, t1, t0)]
kh = 0.75 + 0.25 * frac
w = [t * (0.5 + 0.5 * frac) for t in (t0, t1, t1, t0)]
mb.face(quad, uvs, [(kk * kh, kk * kh, kk * kh, ww) for kk, ww in zip(ks, w)], nrm, 2)
h += rng.uniform(0.32, 0.4)
tier += 1
return mb.build(name, [MAT["bark"], MAT["foliage_core"], MAT["foliage_pine"]])
def make_bush(name, seed):
rng = random.Random(seed)
mb = MeshBuilder()
centre = Vector((0, 0, 0.42))
clumps = [(centre, 0.55)]
n = rng.randint(3, 5)
for k in range(n):
ang = k / n * math.tau + rng.uniform(-0.4, 0.4)
d = rng.uniform(0.35, 0.5)
clumps.append((Vector((math.cos(ang) * d, math.sin(ang) * d, rng.uniform(0.28, 0.45))), rng.uniform(0.36, 0.5)))
for c, r in clumps:
clump(mb, rng, c, r, centre, 0.75, 0, 1, cards_per_r2=60, card_scale=1.25, wind=0.35, ao_floor=0.35)
return mb.build(name, [MAT["foliage_core"], MAT["foliage_bush"]])
def make_rock(name, seed):
rng = random.Random(seed)
mb = MeshBuilder()
v, f = ico(2)
off = Vector((rng.random() * 100, rng.random() * 100, rng.random() * 100))
scale = Vector((rng.uniform(0.9, 1.15), rng.uniform(0.7, 0.9), rng.uniform(0.5, 0.7))) * 0.55
pos = []
for p in v:
k = 1 + 0.34 * mathutils.noise.noise(p * 1.3 + off) + 0.1 * mathutils.noise.noise(p * 3.5 + off)
q = Vector((p.x * scale.x, p.y * scale.y, p.z * scale.z)) * k
q.z = max(q.z, -0.12)
pos.append(q)
# Smooth vertex normals from the displaced shape.
nrm = [Vector((0, 0, 0)) for _ in pos]
for a, b, c in f:
fn = (pos[b] - pos[a]).cross(pos[c] - pos[a])
for i in (a, b, c):
nrm[i] += fn
nrm = [n.normalized() for n in nrm]
for tri in f:
ps = [pos[i] for i in tri]
fn = (ps[1] - ps[0]).cross(ps[2] - ps[0]).normalized()
ax = max(range(3), key=lambda i: abs(fn[i])) # box projection
uvs = [((p.y, p.z) if ax == 0 else (p.x, p.z) if ax == 1 else (p.x, p.y)) for p in ps]
uvs = [(u * 2.2, w * 2.2) for u, w in uvs]
cols = []
for i in tri:
nz = nrm[i].z
moss = max(0.0, min(1.0, (nz - 0.45) / 0.4)) * (0.6 + 0.4 * mathutils.noise.noise(pos[i] * 3 + off))
ao = 0.55 + 0.45 * max(0.0, min(1.0, (pos[i].z + 0.12) / 0.4))
tint = Vector((1, 1, 1)).lerp(Vector((0.55, 1.05, 0.32)), max(0.0, min(1.0, moss)))
cols.append((tint.x * ao, tint.y * ao, tint.z * ao, 0.0))
# Mostly faceted: chunky stylized stone that still catches soft light.
mb.face(ps, uvs, cols, [(fn * 0.65 + nrm[i] * 0.35).normalized() for i in tri], 0)
return mb.build(name, [MAT["rock"]])
# ----------------------------------------------------------------- bridge --
# Deck geometry; demo/DemoScene.cpp mirrors these numbers for walking on it.
BRIDGE_LEN, BRIDGE_W, BRIDGE_RISE = 17.0, 2.6, 0.7
WALL_T, WALL_H = 0.28, 0.5
def deck_z(x):
u = max(-1.0, min(1.0, 2 * x / BRIDGE_LEN))
return 0.05 + BRIDGE_RISE * (1 - u * u)
def ao_stone(z):
return 0.5 + 0.5 * max(0.0, min(1.0, (z + 0.6) / 1.6))
def quad_face(mb, pts, normal, mat, uv_axes, uv_scale, ao=ao_stone):
"""Planar quad with world-projected UVs (uv_axes picks two of x/y/z)."""
uvs = [(p[uv_axes[0]] / uv_scale, p[uv_axes[1]] / uv_scale) for p in pts]
mb.face(pts, uvs, [(ao(p.z),) * 3 + (0.0,) for p in pts], [normal] * 4, mat)
def box(mb, lo, hi, mat, uv_scale=2.0):
x0, y0, z0 = lo
x1, y1, z1 = hi
V = lambda x, y, z: Vector((x, y, z))
quad_face(mb, [V(x0, y0, z1), V(x1, y0, z1), V(x1, y1, z1), V(x0, y1, z1)], Vector((0, 0, 1)), mat, (0, 1), uv_scale)
quad_face(mb, [V(x0, y1, z0), V(x1, y1, z0), V(x1, y0, z0), V(x0, y0, z0)], Vector((0, 0, -1)), mat, (0, 1), uv_scale)
quad_face(mb, [V(x0, y0, z0), V(x1, y0, z0), V(x1, y0, z1), V(x0, y0, z1)], Vector((0, -1, 0)), mat, (0, 2), uv_scale)
quad_face(mb, [V(x1, y1, z0), V(x0, y1, z0), V(x0, y1, z1), V(x1, y1, z1)], Vector((0, 1, 0)), mat, (0, 2), uv_scale)
quad_face(mb, [V(x0, y1, z0), V(x0, y0, z0), V(x0, y0, z1), V(x0, y1, z1)], Vector((-1, 0, 0)), mat, (1, 2), uv_scale)
quad_face(mb, [V(x1, y0, z0), V(x1, y1, z0), V(x1, y1, z1), V(x1, y0, z1)], Vector((1, 0, 0)), mat, (1, 2), uv_scale)
def lathe(mb, profile, centre, mat, sides=16, planar=False):
"""Surface of revolution around +Z from (radius, z) pairs (bottom to top)."""
for i in range(len(profile) - 1):
(r0, z0), (r1, z1) = profile[i], profile[i + 1]
slope = Vector((z1 - z0, 0, -(r1 - r0))).normalized() # outward normal in the r/z plane
for j in range(sides):
a0, a1 = j / sides * math.tau, (j + 1) / sides * math.tau
corners = ((r0, z0, a0), (r0, z0, a1), (r1, z1, a1), (r1, z1, a0))
pts = [centre + Vector((math.cos(a) * r, math.sin(a) * r, z)) for r, z, a in corners]
nrm = [Vector((math.cos(a) * slope.x, math.sin(a) * slope.x, slope.z)) for _, _, a in corners]
uvs = ([((p - centre).x * 1.5, (p - centre).y * 1.5) for p in pts] if planar
else [(a / math.tau * 3, z * 2) for _, z, a in corners])
mb.face(pts, uvs, [(1.0, 1.0, 1.0, 0.0)] * 4, nrm, mat)
def make_bridge(name, seed):
"""Arched stone bridge along +X with parapets, posts, entrance pillars and
two braziers at the -X entrance. Fire sockets: fire_0, fire_1."""
mb = MeshBuilder()
STONE, DECK, IRON, COALS = 0, 1, 2, 3
L, W = BRIDGE_LEN, BRIDGE_W
piers = [-L / 4, 0.0, L / 4]
pier_half = 0.45
abut = L / 2 - 1.3
def under_z(x):
if abs(x) >= abut:
return -0.6
for p in piers:
if abs(x - p) <= pier_half:
return -2.6
edges = [-abut] + [e for p in piers for e in (p - pier_half, p + pier_half)] + [abut]
for a, b in zip(edges[0::2], edges[1::2]):
if a <= x <= b:
u = (x - (a + b) / 2) / ((b - a) / 2)
spring, top = -0.9, deck_z(x) - 0.42
return spring + (top - spring) * math.sqrt(max(0.0, 1 - u * u))
return -0.6
xs = [-L / 2 + i * 0.1 for i in range(int(round(L / 0.1)) + 1)]
yo, yi = W / 2, W / 2 - WALL_T
V = lambda x, y, z: Vector((x, y, z))
for x0, x1 in zip(xs[:-1], xs[1:]):
d0, d1 = deck_z(x0), deck_z(x1)
u0, u1 = under_z(x0), under_z(x1)
t0, t1 = d0 + WALL_H, d1 + WALL_H
up = Vector((-(d1 - d0), 0, x1 - x0)).normalized()
# Deck (cobbles) between the parapets.
quad_face(mb, [V(x0, -yi, d0), V(x1, -yi, d1), V(x1, yi, d1), V(x0, yi, d0)], up, DECK, (0, 1), 2.2,
ao=lambda z: 1.0)
for s in (-1, 1):
# Outer face: from the arch soffit up to the parapet top (CCW seen from outside).
pts = [V(x0, s * yo, u0), V(x1, s * yo, u1), V(x1, s * yo, t1), V(x0, s * yo, t0)]
quad_face(mb, pts if s < 0 else pts[::-1], V(0, s, 0), STONE, (0, 2), 2.0)
# Inner parapet face + parapet cap.
pts = [V(x0, s * yi, d0), V(x1, s * yi, d1), V(x1, s * yi, t1), V(x0, s * yi, t0)]
quad_face(mb, pts if s > 0 else pts[::-1], V(0, -s, 0), STONE, (0, 2), 2.0, ao=lambda z: 0.8)
cap = [V(x0, s * yi, t0), V(x1, s * yi, t1), V(x1, s * yo, t1), V(x0, s * yo, t0)]
quad_face(mb, cap if s > 0 else cap[::-1], up, STONE, (0, 1), 2.0, ao=lambda z: 1.0)
# Arch soffit, seen in the water reflection.
quad_face(mb, [V(x0, yo, u0), V(x1, yo, u1), V(x1, -yo, u1), V(x0, -yo, u0)], V(0, 0, -1), STONE, (0, 1), 2.0,
ao=lambda z: 0.45)
for s in (-1, 1): # end faces
x = s * L / 2
pts = [V(x, -yo, -0.6), V(x, yo, -0.6), V(x, yo, deck_z(x) + WALL_H), V(x, -yo, deck_z(x) + WALL_H)]
quad_face(mb, pts if s > 0 else pts[::-1], V(s, 0, 0), STONE, (1, 2), 2.0)
# Cutwaters on the piers, posts along the parapets.
for p in piers:
box(mb, (p - pier_half - 0.05, -yo - 0.22, -2.6), (p + pier_half + 0.05, yo + 0.22, -0.05), STONE)
box(mb, (p - pier_half - 0.1, -yo - 0.28, -0.05), (p + pier_half + 0.1, yo + 0.28, 0.08), STONE)
for k in range(1, 6):
x = -L / 2 + k * L / 6
for s in (-1, 1):
y0, y1 = sorted((s * (yi - 0.04), s * (yo + 0.06)))
box(mb, (x - 0.2, y0, deck_z(x) - 0.1), (x + 0.2, y1, deck_z(x) + WALL_H + 0.22), STONE)
box(mb, (x - 0.25, y0 - 0.04, deck_z(x) + WALL_H + 0.22), (x + 0.25, y1 + 0.04, deck_z(x) + WALL_H + 0.3), STONE)
# Entrance pillars at both ends; braziers on the -X pair.
fires = []
for end in (-1, 1):
x = end * (L / 2 - 0.35)
for s in (-1, 1):
y = s * (yo + 0.1)
box(mb, (x - 0.38, y - 0.38, -0.6), (x + 0.38, y + 0.38, 1.25), STONE)
box(mb, (x - 0.46, y - 0.46, 1.25), (x + 0.46, y + 0.46, 1.4), STONE)
if end < 0:
c = Vector((x, y, 1.4))
lathe(mb, [(0.16, 0.0), (0.1, 0.08), (0.08, 0.2), (0.14, 0.26), (0.34, 0.38), (0.42, 0.52), (0.38, 0.54),
(0.37, 0.5)], c, IRON)
lathe(mb, [(0.37, 0.5), (0.0, 0.46)], c, COALS, planar=True) # glowing coal bed
fires.append(c + Vector((0, 0, 0.5)))
ob = mb.build(name, [MAT["stone"], MAT["cobble"], MAT["iron"], MAT["coals"]], smooth=False)
for i, p in enumerate(fires):
socket = bpy.data.objects.new(f"fire_{i}", None)
socket.empty_display_size = 0.3
socket.location = p
COLLECTION.objects.link(socket)
socket.parent = ob
return ob
# ------------------------------------------------------------------- main --
def export(objects, path):
"""One .glb with every model as a named root node, so textures are stored once.
Child empties are exported too: the engine reads them as named sockets."""
bpy.ops.object.select_all(action="DESELECT")
for ob in objects:
ob.select_set(True)
for child in ob.children:
child.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
bpy.ops.export_scene.gltf(filepath=path, export_format="GLB", use_selection=True, export_yup=True,
export_apply=True, export_normals=True, export_texcoords=True,
export_vertex_color="ACTIVE", export_image_format="AUTO", export_materials="EXPORT")
MODELS = [
("oak_a", make_oak, 11), ("oak_b", make_oak, 23), ("pine_a", make_pine, 5), ("pine_b", make_pine, 17),
("bush_a", make_bush, 3), ("bush_b", make_bush, 9), ("rock_a", make_rock, 2), ("rock_b", make_rock, 8),
("bridge", make_bridge, 0),
]
# Everything lives in its own collection; nothing else in the open file is touched.
COLLECTION = bpy.data.collections.get("HD2D_Scenery")
if COLLECTION is None:
COLLECTION = bpy.data.collections.new("HD2D_Scenery")
bpy.context.scene.collection.children.link(COLLECTION)
for ob in list(COLLECTION.objects):
me = ob.data
bpy.data.objects.remove(ob, do_unlink=True)
if me and me.users == 0:
bpy.data.meshes.remove(me)
MAT = {
"bark": material("bark", "bark.png"),
"rock": material("rock", "rock.png"),
"foliage_core": material("foliage_core", "leaves_dense.png"),
"foliage_oak": material("foliage_oak", "leaves_oak.png", alpha=True),
"foliage_bush": material("foliage_bush", "leaves_bush.png", alpha=True),
"foliage_pine": material("foliage_pine", "pine_frond.png", alpha=True),
"stone": material("stone", "stone_blocks.png"),
"cobble": material("cobble", "cobble.png"),
"iron": material("iron", "iron.png"),
"coals": material("coals", "coals.png", emission=2.5),
}
os.makedirs(OUT, exist_ok=True)
objects = []
for name, fn, seed in MODELS:
ob = fn(name, seed)
objects.append(ob)
print("built", name, len(ob.data.polygons), "faces")
export(objects, os.path.join(OUT, "scenery.glb")) # all at the origin: the engine uses node names
for i, ob in enumerate(objects):
ob.location.x = i * 6.0 # lay out side by side for previewing in Blender

179
tools/pixellab.py Executable file
View File

@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""PixelLab asset pipeline.
Reads tools/assets.json, generates every asset whose output file is missing
(or all of them with --force / the ones named on the command line), and writes
PNGs into assets/textures/. The API key is read from PIXELLAB_API_KEY in the
environment or from the repo's .env file.
python3 tools/pixellab.py # generate missing assets
python3 tools/pixellab.py tall_grass # (re)generate named assets
python3 tools/pixellab.py --list # list manifest entries
python3 tools/pixellab.py --balance # show account balance
Manifest entry kinds:
"image" -> POST /create-image-pixflux (synchronous)
"tiles" -> POST /create-tiles-pro, polls GET /tiles-pro/{id}; writes
<out>_<n>.png for every variation plus <out> for "pick" index
"""
import base64
import json
import os
import sys
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
API = "https://api.pixellab.ai/v2"
MANIFEST = ROOT / "tools" / "assets.json"
OUT_DIR = ROOT / "assets" / "textures"
def api_key() -> str:
key = os.environ.get("PIXELLAB_API_KEY")
if key:
return key.strip()
env = ROOT / ".env"
if env.exists():
for line in env.read_text().splitlines():
line = line.strip()
if line.startswith("PIXELLAB_API_KEY="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
sys.exit("PIXELLAB_API_KEY not set (environment or .env)")
def request(method: str, path: str, body=None, timeout=180):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
API + path,
data=data,
method=method,
headers={
"Authorization": f"Bearer {api_key()}",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, json.loads(r.read() or b"null")
except urllib.error.HTTPError as e:
return e.code, e.read().decode(errors="replace")
def save_b64(b64: str, path: Path):
path.parent.mkdir(parents=True, exist_ok=True)
if "," in b64[:64]: # data URL prefix
b64 = b64.split(",", 1)[1]
path.write_bytes(base64.b64decode(b64))
# Manifest keys consumed by this script, never sent to the API.
LOCAL_KEYS = {"kind", "out", "pick", "tile_id"}
def api_body(spec):
return {k: v for k, v in spec.items() if k not in LOCAL_KEYS}
def gen_image(name, spec):
body = api_body(spec)
status, res = request("POST", "/create-image-pixflux", body)
if status != 200:
raise RuntimeError(f"{name}: HTTP {status}: {res}")
out = OUT_DIR / spec["out"]
save_b64(res["image"]["base64"], out)
return [out]
def gen_tiles(name, spec):
# A manifest "tile_id" re-downloads an existing (already paid for) tile set
# instead of generating a new one. Delete it to regenerate.
tile_id = spec.get("tile_id")
if not tile_id:
body = api_body(spec)
status, res = request("POST", "/create-tiles-pro", body)
if status not in (200, 202):
raise RuntimeError(f"{name}: HTTP {status}: {res}")
tile_id = res["tile_id"]
print(f"[..] {name}: tile_id {tile_id} (add to manifest to reuse)", flush=True)
deadline = time.time() + 600
while time.time() < deadline:
status, res = request("GET", f"/tiles-pro/{tile_id}")
if status == 423: # still processing
time.sleep(6)
continue
if status != 200:
raise RuntimeError(f"{name}: poll HTTP {status}: {res}")
if res.get("storage_urls"):
break
if res.get("status") == "failed":
raise RuntimeError(f"{name}: generation failed: {res}")
time.sleep(6)
else:
raise RuntimeError(f"{name}: timed out")
stem = Path(spec["out"]).stem
outs = []
urls = res["storage_urls"]
keys = sorted(urls, key=lambda k: int("".join(c for c in k if c.isdigit()) or 0))
for i, key in enumerate(keys):
p = OUT_DIR / f"{stem}_{i}.png"
p.parent.mkdir(parents=True, exist_ok=True)
# The storage host rejects urllib's default User-Agent with 403.
dl = urllib.request.Request(urls[key], headers={"User-Agent": "hd2d-asset-pipeline/1.0"})
with urllib.request.urlopen(dl, timeout=120) as r:
p.write_bytes(r.read())
outs.append(p)
pick = spec.get("pick", 0)
if outs:
(OUT_DIR / spec["out"]).write_bytes(outs[min(pick, len(outs) - 1)].read_bytes())
return outs
def generate(name, spec):
kind = spec.get("kind", "image")
fn = {"image": gen_image, "tiles": gen_tiles}[kind]
t0 = time.time()
outs = fn(name, spec)
print(f"[ok] {name} ({time.time() - t0:.1f}s) -> {', '.join(p.name for p in outs)}", flush=True)
def main(argv):
manifest = json.loads(MANIFEST.read_text())
if "--balance" in argv:
print(request("GET", "/balance")[1])
return
if "--list" in argv:
for name, spec in manifest.items():
have = (OUT_DIR / spec["out"]).exists()
print(f"{'x' if have else ' '} {name:20s} {spec['out']}")
return
force = "--force" in argv
names = [a for a in argv if not a.startswith("--")]
unknown = [n for n in names if n not in manifest]
if unknown:
sys.exit(f"unknown asset(s): {unknown}")
todo = {
n: s
for n, s in manifest.items()
if (n in names) or (not names and (force or not (OUT_DIR / s["out"]).exists()))
}
if not todo:
print("all assets present")
return
failures = 0
with ThreadPoolExecutor(max_workers=4) as pool:
futs = {pool.submit(generate, n, s): n for n, s in todo.items()}
for f, n in futs.items():
try:
f.result()
except Exception as e: # report every failure, keep going
failures += 1
print(f"[fail] {n}: {e}", file=sys.stderr, flush=True)
sys.exit(1 if failures else 0)
if __name__ == "__main__":
main(sys.argv[1:])

270
tools/scenery_textures.py Executable file
View File

@@ -0,0 +1,270 @@
#!/usr/bin/env python3
"""Paints the high-resolution, hand-painted-style textures used by the 3D
scenery models (trees, bushes, rocks). Output: assets/models/src/*.png.
These are deliberately *not* pixel art: in HD-2D, environment props are real
3D models with higher-resolution painterly textures, while characters and tiny
props stay as pixel-art billboards.
python3 tools/scenery_textures.py (needs numpy + Pillow)
Run tools/blender_scenery.py afterwards to rebuild the .glb models.
"""
import math
import random
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFilter
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "assets" / "models" / "src"
def fft_noise(h, w, power=2.0, stretch=(1.0, 1.0), seed=0):
"""Tileable fractal noise in [0, 1] (1/f^power spectrum)."""
rng = np.random.default_rng(seed)
fy = np.fft.fftfreq(h)[:, None] * stretch[0]
fx = np.fft.fftfreq(w)[None, :] * stretch[1]
f = np.sqrt(fx * fx + fy * fy)
f[0, 0] = 1.0
spectrum = (rng.normal(size=(h, w)) + 1j * rng.normal(size=(h, w))) / f**power
spectrum[0, 0] = 0
n = np.real(np.fft.ifft2(spectrum))
n -= n.min()
return n / n.max()
def lerp_colors(t, stops):
"""Maps t in [0,1] (array) through a list of (pos, (r,g,b)) stops."""
t = np.clip(t, 0.0, 1.0)
out = np.zeros(t.shape + (3,))
pos = [p for p, _ in stops]
for c in range(3):
out[..., c] = np.interp(t, pos, [col[c] for _, col in stops])
return out
def leaf_polygon(cx, cy, angle, length, width, steps=10):
pts_l, pts_r = [], []
ca, sa = math.cos(angle), math.sin(angle)
for i in range(steps + 1):
t = i / steps
x = t * length
y = width * math.sin(math.pi * t) ** 0.75 * (1.0 - 0.25 * t)
for side, pts in ((1, pts_l), (-1, pts_r)):
px, py = x, side * y
pts.append((cx + px * ca - py * sa, cy + px * sa + py * ca))
return pts_l, pts_r
def shade(col, k):
return tuple(int(max(0, min(255, c * k))) for c in col)
def draw_leaf(draw, cx, cy, angle, length, width, base):
left, right = leaf_polygon(cx, cy, angle, length, width)
tip = left[-1]
# Two halves with different brightness read as a folded leaf.
draw.polygon(left + right[::-1], fill=base + (255,))
draw.polygon(left + [tip, (cx, cy)], fill=shade(base, 1.18) + (255,))
draw.line([(cx, cy), tip], fill=shade(base, 0.72) + (255,), width=max(1, int(width * 0.12)))
LEAF_PALETTE = [(34, 82, 30), (52, 112, 34), (78, 146, 40), (112, 176, 52), (150, 204, 74)]
def leaf_cluster(size=1024, seed=1, count=320, palette=LEAF_PALETTE):
"""A round clump of overlapping leaves on transparency (canopy card)."""
rng = random.Random(seed)
S = size * 2
img = Image.new("RGBA", (S, S), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
c = S / 2
leaves = []
for _ in range(count):
r = (rng.random() ** 0.6) * S * 0.40
a = rng.random() * math.tau
leaves.append((r, a))
leaves.sort(key=lambda la: -la[0] + rng.random() * S * 0.1) # outer first, centre on top
for r, a in leaves:
x, y = c + r * math.cos(a), c + r * math.sin(a)
ang = a + rng.uniform(-0.9, 0.9) if r > S * 0.05 else rng.random() * math.tau
length = S * rng.uniform(0.05, 0.085)
depth = 1.0 - r / (S * 0.42) # centre leaves are on top and lighter
idx = min(len(palette) - 1, max(0, int((depth * 0.7 + rng.random() * 0.5) * len(palette))))
draw_leaf(d, x, y, ang, length, length * 0.42, palette[idx])
img = img.resize((size, size), Image.LANCZOS)
a = np.array(img)
a[..., 3] = np.where(a[..., 3] > 110, 255, 0) # hard cutout
return Image.fromarray(a)
def leaf_dense(size=1024, seed=2, palette=LEAF_PALETTE):
"""Opaque tileable leaf mass for canopy cores (fills gaps between cards)."""
rng = random.Random(seed)
S = size * 2
img = Image.new("RGBA", (S, S), shade(palette[0], 0.8) + (255,))
d = ImageDraw.Draw(img)
for _ in range(900):
x, y = rng.random() * S, rng.random() * S
ang = rng.random() * math.tau
length = S * rng.uniform(0.04, 0.065)
base = palette[min(len(palette) - 1, int(rng.random() ** 1.4 * len(palette)))]
for ox in (-S, 0, S): # wrap so the texture tiles
for oy in (-S, 0, S):
if -length < x + ox < S + length and -length < y + oy < S + length:
draw_leaf(d, x + ox, y + oy, ang, length, length * 0.42, base)
return img.resize((size, size), Image.LANCZOS)
NEEDLE_PALETTE = [(24, 70, 52), (36, 94, 66), (54, 122, 80), (84, 152, 96)]
def pine_frond(w=1024, h=512, seed=3):
"""A drooping pine branch: stem along +u from the left edge, needles on both sides."""
rng = random.Random(seed)
W, H = w * 2, h * 2
img = Image.new("RGBA", (W, H), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
cy = H * 0.5
def stem_y(x):
return cy + (x / W) ** 2 * H * 0.08
for layer in range(3):
for i in range(520):
t = rng.random()
x = t * W * 0.96
y = stem_y(x)
env = math.sin(math.pi * min(1.0, t * 1.1)) ** 0.6 * (1.0 - 0.45 * t) # leaf-shaped outline
side = rng.choice((-1, 1))
length = H * 0.5 * env * rng.uniform(0.6, 1.0)
ang = side * rng.uniform(0.55, 1.05) - 0.25 * side * t
ex, ey = x + math.cos(ang) * length * 0.55, y + math.sin(ang) * length
col = NEEDLE_PALETTE[min(3, layer + int(rng.random() * 2))]
d.line([(x, y), (ex, ey)], fill=col + (255,), width=int(rng.uniform(6, 10)))
d.line([(x, y), (ex, ey)], fill=shade(col, 1.25) + (255,), width=2)
d.line([(x0, stem_y(x0)) for x0 in range(0, int(W * 0.97), 8)], fill=(58, 70, 44, 255), width=4)
img = img.resize((w, h), Image.LANCZOS)
a = np.array(img)
a[..., 3] = np.where(a[..., 3] > 110, 255, 0)
return Image.fromarray(a)
def bark(w=512, h=1024, seed=4):
n = fft_noise(h, w, power=1.25, stretch=(6.0, 1.0), seed=seed) # long vertical fibres
fine = fft_noise(h, w, power=0.9, stretch=(3.0, 1.0), seed=seed + 1)
v = n * 0.75 + fine * 0.25
col = lerp_colors(v, [(0.0, (34, 22, 16)), (0.35, (66, 44, 30)), (0.6, (104, 72, 48)), (1.0, (150, 112, 78))])
crev = np.clip((0.33 - n) * 6.0, 0, 1)[..., None]
col = col * (1 - 0.65 * crev)
return Image.fromarray(col.astype(np.uint8), "RGB")
def rock(size=1024, seed=5):
base = fft_noise(size, size, power=1.8, seed=seed)
fine = fft_noise(size, size, power=0.9, seed=seed + 1)
ridge = 1.0 - np.abs(fft_noise(size, size, power=1.5, seed=seed + 2) * 2 - 1)
v = np.clip((base * 0.6 + fine * 0.4 - 0.5) * 1.6 + 0.5, 0, 1)
col = lerp_colors(v, [(0.0, (52, 52, 56)), (0.4, (104, 102, 98)), (0.7, (148, 144, 134)), (1.0, (196, 190, 176))])
cracks = np.clip((ridge - 0.965) * 18.0, 0, 1)[..., None]
col = col * (1 - 0.55 * cracks)
lichen = np.clip((fft_noise(size, size, power=1.4, seed=seed + 3) - 0.72) * 8.0, 0, 1)[..., None]
col = col * (1 - lichen * 0.35) + np.array([150, 160, 110]) * lichen * 0.35
return Image.fromarray(col.astype(np.uint8), "RGB")
def voronoi(size, count, seed):
"""Tileable Voronoi: (F1, F2, cell id) per pixel."""
rng = np.random.default_rng(seed)
pts = rng.random((count, 2)) * size
yy, xx = np.mgrid[0:size, 0:size].astype(np.float32)
f1 = np.full((size, size), 1e9, np.float32)
f2 = np.full((size, size), 1e9, np.float32)
ids = np.zeros((size, size), np.int32)
for i, (px, py) in enumerate(pts):
for ox in (-size, 0, size):
for oy in (-size, 0, size):
d = np.hypot(xx - (px + ox), yy - (py + oy))
closer = d < f1
f2 = np.where(closer, f1, np.minimum(f2, d))
ids = np.where(closer, i, ids)
f1 = np.where(closer, d, f1)
return f1, f2, ids
def cobble(size=1024, seed=8, count=70):
"""Large worn flagstones with dark mortar (bridge deck)."""
f1, f2, ids = voronoi(size, count, seed)
rng = np.random.default_rng(seed + 1)
tone = rng.uniform(0.75, 1.15, count)[ids]
edge = f2 - f1
cell = size / math.sqrt(count)
dome = np.clip(1.0 - f1 / (cell * 0.75), 0, 1) ** 0.5 # stones bulge slightly
grain = fft_noise(size, size, power=1.1, seed=seed + 2)
v = np.clip((0.45 + 0.35 * dome + 0.35 * (grain - 0.5)) * tone, 0, 1)
col = lerp_colors(v, [(0.0, (58, 56, 54)), (0.45, (112, 108, 100)), (0.75, (150, 144, 132)), (1.0, (186, 178, 162))])
mortar = np.clip(1.0 - edge / 9.0, 0, 1)[..., None]
col = col * (1 - mortar) + np.array([40, 38, 34]) * mortar
bevel = np.clip(1.0 - (edge - 9.0) / 14.0, 0, 1)[..., None] * (1 - mortar) # darker stone rims
col = col * (1 - 0.3 * bevel)
moss_mask = np.clip((fft_noise(size, size, power=1.6, seed=seed + 3) - 0.68) * 6.0, 0, 1)[..., None] * mortar
col = col * (1 - moss_mask) + np.array([70, 104, 40]) * moss_mask
return Image.fromarray(col.astype(np.uint8), "RGB")
def stone_blocks(size=1024, seed=9, rows=4, cols=3):
"""Staggered dressed stone blocks with mortar (bridge walls, pillars)."""
rng = np.random.default_rng(seed)
yy, xx = np.mgrid[0:size, 0:size].astype(np.float32)
row_h = size / rows
r = (yy // row_h).astype(int)
offset = (r % 2) * 0.5 * (size / cols) + rng.uniform(-20, 20, rows)[r]
bw = size / cols
xs = (xx + offset) % size
c = (xs // bw).astype(int)
lx, ly = xs - c * bw, yy - r * row_h
edge = np.minimum(np.minimum(lx, bw - lx), np.minimum(ly, row_h - ly))
tone = rng.uniform(0.8, 1.15, (rows, cols + 1))[r, c]
grain = fft_noise(size, size, power=1.2, seed=seed + 1)
v = np.clip((0.55 + 0.4 * (grain - 0.5)) * tone, 0, 1)
col = lerp_colors(v, [(0.0, (60, 58, 56)), (0.5, (126, 120, 110)), (1.0, (182, 174, 158))])
chip = np.clip(1.0 - edge / 16.0, 0, 1)[..., None]
col = col * (1 - 0.35 * chip)
mortar = (edge < 5.0)[..., None]
col = np.where(mortar, np.array([46, 44, 40]), col)
return Image.fromarray(col.astype(np.uint8), "RGB")
def iron(size=512, seed=10):
v = fft_noise(size, size, power=1.3, seed=seed)
col = lerp_colors(v, [(0.0, (22, 20, 20)), (0.6, (48, 44, 42)), (1.0, (86, 70, 56))]) # rust in highlights
return Image.fromarray(col.astype(np.uint8), "RGB")
def coals(size=256, seed=11):
f1, f2, ids = voronoi(size, 40, seed)
edge = np.clip((f2 - f1) / 10.0, 0, 1)
hot = np.random.default_rng(seed).uniform(0.4, 1.0, 40)[ids] * edge
col = lerp_colors(hot, [(0.0, (20, 8, 4)), (0.5, (160, 50, 10)), (1.0, (255, 170, 60))])
return Image.fromarray(col.astype(np.uint8), "RGB")
def main():
OUT.mkdir(parents=True, exist_ok=True)
leaf_cluster(seed=1).save(OUT / "leaves_oak.png")
leaf_cluster(seed=7, count=360, palette=[(30, 84, 40), (44, 116, 46), (70, 150, 52), (104, 182, 62), (150, 210, 86)]).save(OUT / "leaves_bush.png")
leaf_dense().save(OUT / "leaves_dense.png")
pine_frond().save(OUT / "pine_frond.png")
bark().save(OUT / "bark.png")
rock().save(OUT / "rock.png")
cobble().save(OUT / "cobble.png")
stone_blocks().save(OUT / "stone_blocks.png")
iron().save(OUT / "iron.png")
coals().save(OUT / "coals.png")
print("wrote", ", ".join(sorted(p.name for p in OUT.glob("*.png"))))
if __name__ == "__main__":
main()