init
This commit is contained in:
78
tools/assets.json
Normal file
78
tools/assets.json
Normal 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
527
tools/blender_scenery.py
Normal 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
179
tools/pixellab.py
Executable 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
270
tools/scenery_textures.py
Executable 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()
|
||||
Reference in New Issue
Block a user