Add rooftop elevator, lounge, volumetric city, river and animated traffic. Prune invisible mesh faces, clip CPU surfaces, streamline compositing and add automatic/F8 roof quality profiles with renderer diagnostics. Include dartboard/reticle improvements, tracked fortune orb numerals and fate-session visuals, native regression tests, compatible jukebox IDs and updated shipping executables.
319 lines
16 KiB
Python
319 lines
16 KiB
Python
"""Offline city meshes. Playback, GL submission and compositing remain MASM."""
|
|
from pathlib import Path
|
|
import json
|
|
import math
|
|
import struct
|
|
from collections import Counter
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
OUT = ROOT/"assets/animations/rooftop"
|
|
ART = ROOT/"assets/sprites/rooftop"
|
|
|
|
|
|
class Mesh:
|
|
def __init__(self):
|
|
self.triangles = []
|
|
|
|
def quad(self, material, points, uv=None, smooth=None):
|
|
# Points use the game's X/Y ground plane and Z up. GL receives X/Z/Y.
|
|
a,b,c = points[:3]
|
|
u = [b[i]-a[i] for i in range(3)]
|
|
v = [c[i]-a[i] for i in range(3)]
|
|
normal = (u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0])
|
|
size = math.sqrt(sum(x*x for x in normal))
|
|
if size < 1e-8:
|
|
return
|
|
normal = tuple(x/size for x in normal)
|
|
uv = uv or [(0,0),(1,0),(1,1),(0,1)]
|
|
for ids in ((0,1,2),(0,2,3)):
|
|
if len({points[i] for i in ids}) < 3:
|
|
continue
|
|
vertices = []
|
|
for i in ids:
|
|
x,y,z = points[i]
|
|
nx,ny,nz = smooth[i] if smooth else normal
|
|
# Vertical texture convention: the top of a facade is v=0.
|
|
vertices.extend((x,z,y,nx,nz,ny,*uv[i]))
|
|
self.triangles.append((material,vertices))
|
|
|
|
def box(self, x0,x1,y0,y1,z0,z1,material,roof=None,repeat_v=1):
|
|
if min(x1-x0,y1-y0,z1-z0) <= 0:
|
|
return
|
|
# Explicit winding/normals face outwards; negative scale is never used.
|
|
uv = [(0,repeat_v),(1,repeat_v),(1,0),(0,0)]
|
|
self.quad(material,[(x0,y0,z0),(x1,y0,z0),(x1,y0,z1),(x0,y0,z1)],uv)
|
|
self.quad(material,[(x1,y1,z0),(x0,y1,z0),(x0,y1,z1),(x1,y1,z1)],uv)
|
|
self.quad(material,[(x0,y1,z0),(x0,y0,z0),(x0,y0,z1),(x0,y1,z1)],uv)
|
|
self.quad(material,[(x1,y0,z0),(x1,y1,z0),(x1,y1,z1),(x1,y0,z1)],uv)
|
|
self.horizontal(x0,x1,y0,y1,z1,roof or material)
|
|
|
|
def horizontal(self,x0,x1,y0,y1,z,material,uv=None):
|
|
points = [(x0,y1,z),(x1,y1,z),(x1,y0,z),(x0,y0,z)]
|
|
uv = uv or [(0,0),(1,0),(1,1),(0,1)]
|
|
self.quad(material,list(reversed(points)),list(reversed(uv)))
|
|
|
|
def ellipsoid(self,x,y,z,rx,ry,rz,material,upper=False,rings=10,sides=24):
|
|
end = math.pi/2 if upper else math.pi
|
|
def point(t,p):
|
|
nx,ny,nz = math.sin(t)*math.cos(p),math.sin(t)*math.sin(p),math.cos(t)
|
|
n = (nx/rx,ny/ry,nz/rz)
|
|
length = math.sqrt(sum(v*v for v in n))
|
|
return (x+rx*nx,y+ry*ny,z+rz*nz),tuple(v/length for v in n)
|
|
for row in range(rings):
|
|
t0,t1 = max(.0001,row*end/rings),(row+1)*end/rings
|
|
for col in range(sides):
|
|
p0,p1 = col*math.tau/sides,(col+1)*math.tau/sides
|
|
samples = [point(t0,p0),point(t1,p0),point(t1,p1),point(t0,p1)]
|
|
self.quad(material,[p for p,n in samples],
|
|
[(col/sides,row/rings),(col/sides,(row+1)/rings),
|
|
((col+1)/sides,(row+1)/rings),((col+1)/sides,row/rings)],
|
|
[n for p,n in samples])
|
|
|
|
def cylinder(self,x,y,z0,z1,radius,material,sides=16,repeat=1):
|
|
for col in range(sides):
|
|
a,b = col*math.tau/sides,(col+1)*math.tau/sides
|
|
na,nb = (math.cos(a),math.sin(a),0),(math.cos(b),math.sin(b),0)
|
|
self.quad(material,[(x+radius*na[0],y+radius*na[1],z0),
|
|
(x+radius*nb[0],y+radius*nb[1],z0),
|
|
(x+radius*nb[0],y+radius*nb[1],z1),
|
|
(x+radius*na[0],y+radius*na[1],z1)],
|
|
uv=[(col*repeat/sides,1),((col+1)*repeat/sides,1),
|
|
((col+1)*repeat/sides,0),(col*repeat/sides,0)],
|
|
smooth=[na,nb,nb,na])
|
|
self.quad(material,[(x,y,z1),(x+radius*na[0],y+radius*na[1],z1),
|
|
(x+radius*nb[0],y+radius*nb[1],z1),
|
|
(x+radius*nb[0],y+radius*nb[1],z1)])
|
|
|
|
def hip(self,x0,x1,y0,y1,z,height,material=196):
|
|
if x1-x0 >= y1-y0:
|
|
ridge = [(x0+1,(y0+y1)/2,z+height),(x1-1,(y0+y1)/2,z+height)]
|
|
self.quad(material,[(x0,y0,z),(x1,y0,z),ridge[1],ridge[0]],[(0,0),((x1-x0)/4,0),((x1-x0)/4,1),(0,1)])
|
|
self.quad(material,[(x1,y1,z),(x0,y1,z),ridge[0],ridge[1]],[(0,0),((x1-x0)/4,0),((x1-x0)/4,1),(0,1)])
|
|
self.quad(material,[(x0,y1,z),(x0,y0,z),ridge[0],ridge[0]])
|
|
self.quad(material,[(x1,y0,z),(x1,y1,z),ridge[1],ridge[1]])
|
|
else:
|
|
# Transposing X/Y reverses winding; save() normalises every triangle.
|
|
cy = (x0+x1)/2
|
|
a,b = (cy,y0+1,z+height),(cy,y1-1,z+height)
|
|
self.quad(material,[(x0,y1,z),(x0,y0,z),a,b],[(0,0),((y1-y0)/4,0),((y1-y0)/4,1),(0,1)])
|
|
self.quad(material,[(x1,y0,z),(x1,y1,z),b,a],[(0,0),((y1-y0)/4,0),((y1-y0)/4,1),(0,1)])
|
|
self.quad(material,[(x0,y0,z),(x1,y0,z),a,a])
|
|
self.quad(material,[(x1,y1,z),(x0,y1,z),b,b])
|
|
|
|
def save(self,name,static=False):
|
|
original_count = len(self.triangles)
|
|
oriented = []
|
|
for mat,packed in self.triangles:
|
|
vertices = [packed[i:i+8] for i in (0,8,16)]
|
|
a,b,c = [v[:3] for v in vertices]
|
|
u,v = [b[i]-a[i] for i in range(3)],[c[i]-a[i] for i in range(3)]
|
|
normal = [u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0]]
|
|
if sum(x*x for x in normal) < 1e-16:
|
|
continue
|
|
expected = [sum(vertex[i+3] for vertex in vertices) for i in range(3)]
|
|
if sum(x*y for x,y in zip(normal,expected)) < 0:
|
|
vertices[1],vertices[2] = vertices[2],vertices[1]
|
|
normal = [-x for x in normal]
|
|
if static:
|
|
# Union of every standing/seated/leaning eye position, in GL X/Z/Y axes.
|
|
bounds = ((11.0,29.0),(1.05,1.8),(1.0,21.0))
|
|
maximum = sum(n*((hi if n >= 0 else lo)-p)
|
|
for n,(lo,hi),p in zip(normal,bounds,a))
|
|
if maximum < -1e-7:
|
|
continue
|
|
oriented.append((mat,[value for vertex in vertices for value in vertex]))
|
|
self.triangles = oriented
|
|
self.triangles.sort(key=lambda t:t[0])
|
|
payload = b"".join(struct.pack("<I24f",mat,*vertices) for mat,vertices in self.triangles)
|
|
assert len(payload) == len(self.triangles)*100
|
|
assert all(161 <= mat <= 209 for mat,_ in self.triangles)
|
|
assert all(math.isfinite(v) for _,vs in self.triangles for v in vs)
|
|
assert len(self.triangles) <= 65000
|
|
(OUT/name).write_bytes(struct.pack("<4sII",b"RGM1",len(self.triangles),len(payload))+payload)
|
|
self.removed = original_count-len(self.triangles)
|
|
return len(self.triangles)
|
|
|
|
|
|
def main():
|
|
city = json.loads((ART/"district-map-v1.json").read_text())
|
|
scene = Mesh()
|
|
# The terrace is on a real sixty-metre high-rise, not a floating platform.
|
|
scene.box(10.9,29.1,.9,21.1,-60,0,200,roof=165,repeat_v=3)
|
|
scene.horizontal(10.9,29.1,.9,21.1,.004,161,[(0,10),(9,10),(9,0),(0,0)])
|
|
# Terrain is actual split geometry, leaving a lowered water channel.
|
|
def patches(lo,hi):
|
|
return [(lo,70,lo,60,False),(70,hi,lo,29,False),(lo,hi,76,hi,False),
|
|
(lo,hi,60,76,True),(70,hi,29,60,True)]
|
|
def world_uv(x0,x1,y0,y1,scale):
|
|
return [(x0/scale,y1/scale),(x1/scale,y1/scale),
|
|
(x1/scale,y0/scale),(x0/scale,y0/scale)]
|
|
for x0,x1,y0,y1,water in patches(-2000,2000):
|
|
scene.horizontal(x0,x1,y0,y1,-62 if water else -60,198 if water else 162,
|
|
world_uv(x0,x1,y0,y1,12 if water else 4))
|
|
def subtract(rect,obstacle):
|
|
a,b,c,d = rect
|
|
x0,x1,y0,y1 = obstacle
|
|
x0,x1,y0,y1 = max(a,x0),min(b,x1),max(c,y0),min(d,y1)
|
|
if x0 >= x1 or y0 >= y1:
|
|
return [rect]
|
|
return [r for r in ((a,x0,c,d),(x1,b,c,d),(x0,x1,c,y0),(x0,x1,y1,d))
|
|
if r[1]-r[0] > .01 and r[3]-r[2] > .01]
|
|
def road(rect,vertical=False):
|
|
pieces = [rect]
|
|
obstacles = [(-2000,2000,58,79),(68,2000,26,81)]
|
|
if vertical:
|
|
obstacles += [(-2000,2000,29+36*i-4,29+36*i+4) for i in range(-16,17)]
|
|
for obstacle in obstacles:
|
|
pieces = [p for r in pieces for p in subtract(r,obstacle)]
|
|
for a,b,c,d in pieces:
|
|
roads.append((a,b,c,d))
|
|
scene.horizontal(a,b,c,d,-59.98,197,world_uv(a,b,c,d,4))
|
|
roads = []
|
|
for i in range(-16,17):
|
|
x = 38+36*i
|
|
road((x-4,x+4,-600,600),vertical=True)
|
|
y = 29+36*i
|
|
road((-600,600,y-4,y+4))
|
|
# Small lane dashes and pedestrian crossings give the streets a readable scale.
|
|
for gx in range(-6,7):
|
|
x = 38+36*gx
|
|
for y in range(-205,231,9):
|
|
if not 54 < y < 82 and not (x > 68 and 24 < y < 82):
|
|
scene.horizontal(x-.07,x+.07,y,y+3,-59.965,168)
|
|
for gy in range(-6,7):
|
|
y = 29+36*gy
|
|
if 54 < y < 82 or (x > 68 and 24 < y < 82):
|
|
continue
|
|
for stripe in range(7):
|
|
sx = x-3.2+stripe*.95
|
|
scene.horizontal(sx,sx+.45,y+5,y+7,-59.96,168)
|
|
for index,(x0,x1,y0,y1,roof,mat,kind) in enumerate(city["buildings"]):
|
|
modern = mat >= 199
|
|
repeat = max(1,round((roof+60)/26)) if modern else 1
|
|
scene.box(x0,x1,y0,y1,-60,roof,mat,repeat_v=repeat)
|
|
scene.box(x0-.18,x1+.18,y0-.18,y1+.18,roof,roof+.18,165 if modern else 168)
|
|
distance = math.hypot((x0+x1)/2-20,(y0+y1)/2-11)
|
|
if not modern and distance < 220:
|
|
scene.box(x0-.12,x1+.12,y0-.12,y1+.12,-60,-58.8,162)
|
|
for z in (roof-.55,roof-.3):
|
|
scene.box(x0-.25,x1+.25,y0-.25,y1+.25,z,z+.12,168)
|
|
if distance < 150:
|
|
for x in (x0,x1-.28):
|
|
for y in (y0,y1-.28):
|
|
scene.box(x-.08,x+.36,y-.08,y+.36,-58.8,roof-.5,168)
|
|
scene.hip(x0-.25,x1+.25,y0-.25,y1+.25,roof+.18,2.4)
|
|
scene.box(x0+.8,x0+1.45,y0+.8,y0+1.35,roof+1.2,roof+3.2,190,roof=162)
|
|
# Recessed dormer volumes interrupt long, otherwise empty roof slopes.
|
|
if x1-x0 > 12 and distance < 150:
|
|
for x in range(math.ceil(x0+3),math.floor(x1-2),6):
|
|
scene.box(x-.55,x+.55,y0+.8,y0+1.55,roof+.45,roof+1.45,168)
|
|
scene.quad(200,[(x-.4,y0+.79,roof+.6),(x+.4,y0+.79,roof+.6),
|
|
(x+.4,y0+.79,roof+1.25),(x-.4,y0+.79,roof+1.25)])
|
|
scene.hip(x-.65,x+.65,y0+.7,y0+1.65,roof+1.45,.4)
|
|
else:
|
|
scene.horizontal(x0,x1,y0,y1,roof+.19,196)
|
|
# Static landmark records share their exact footprints with the MASM world.
|
|
data = (ROOT/"src_graphics/rooftop_district_data.inc").read_text()
|
|
section = data.split("roofLandmarkParts3D LABEL DWORD",1)[1].split("roofFog3D",1)[0]
|
|
for line in section.splitlines():
|
|
if not line.strip().startswith("dd "):
|
|
continue
|
|
values = list(map(int,line.strip()[3:].split(",")))
|
|
kind,coords,mat = values[0],[v/65536 for v in values[1:7]],values[7]
|
|
# The dome's old rectangular drum is replaced with a proper circular one.
|
|
if kind == 0 and 7 < coords[0] < 12 and 128 < coords[3] < 134 and coords[4] > -30:
|
|
continue
|
|
if kind == 0:
|
|
x0,x1,y0,y1,z0,z1 = coords
|
|
if x1-x0 < 1.1 and y1-y0 < 1.1 and z1-z0 > 3:
|
|
scene.cylinder((x0+x1)/2,(y0+y1)/2,z0,z1,(x1-x0)/2,mat)
|
|
scene.box(x0-.12,x1+.12,y0-.12,y1+.12,z1-.25,z1,mat)
|
|
else:
|
|
scene.box(*coords,mat)
|
|
elif kind == 1:
|
|
scene.ellipsoid(*coords,mat,upper=True,rings=14,sides=48)
|
|
elif kind == 2:
|
|
x,y,z,rx,ry,h = coords
|
|
h *= .65
|
|
for i in range(8):
|
|
a,b = i*math.tau/8,(i+1)*math.tau/8
|
|
scene.quad(mat,[(x+rx*math.cos(a),y+ry*math.sin(a),z),
|
|
(x+rx*math.cos(b),y+ry*math.sin(b),z),
|
|
(x,y,z+h),(x,y,z+h)])
|
|
# Cathedral drum, portico and a solid entablature under the copper dome.
|
|
scene.cylinder(20,119,-22,-10,9.5,191,sides=48,repeat=6)
|
|
for z,radius in ((-22,10.3),(-16,10.0),(-10.5,12.4)):
|
|
scene.cylinder(20,119,z,z+.45,radius,168,sides=48,repeat=6)
|
|
for step in range(4):
|
|
scene.box(3+step*.4,37-step*.4,95+step*.5,104,-60+step*.35,-59.65+step*.35,162)
|
|
for x in (6,11.5,17,22.5,28,33.5):
|
|
scene.cylinder(x,99,-58.6,-40,.55,168,sides=16)
|
|
scene.cylinder(x,99,-58.6,-57.8,.8,162,sides=16)
|
|
scene.cylinder(x,99,-40,-39.5,.8,168,sides=16)
|
|
scene.box(3,37,97,104,-39.5,-38.5,168)
|
|
scene.quad(168,[(3,96.99,-38.5),(37,96.99,-38.5),(20,96.99,-33),(20,96.99,-33)])
|
|
scene.hip(2.8,37.2,96.8,104.2,-38.5,5.5)
|
|
for x in sorted(set(city["bridges"]+city["river_bridges"])):
|
|
start,end = (28,77) if x >= 70 else (59,77)
|
|
scene.horizontal(x-3,x+3,start,end,-59.9,197,world_uv(x-3,x+3,start,end,4))
|
|
for side in (x-3,x+3):
|
|
for i in range(24):
|
|
y0,y1 = start+(end-start)*i/24,start+(end-start)*(i+1)/24
|
|
mid,radius = (start+end)/2,(end-start)/2
|
|
z0 = -62+1.8*math.sqrt(max(0,1-((y0-mid)/radius)**2))
|
|
z1 = -62+1.8*math.sqrt(max(0,1-((y1-mid)/radius)**2))
|
|
points = [(side,y0,z0),(side,y1,z1),(side,y1,-60),(side,y0,-60)]
|
|
scene.quad(162,list(reversed(points)) if side < x else points)
|
|
for y in range(start,end+1,2):
|
|
scene.box(side-.1,side+.1,y-.1,y+.1,-60,-59,165)
|
|
scene.box(side-.08,side+.08,start,end,-59.1,-59,165)
|
|
# Raised embankments, a tree-lined promenade and small piers follow the river.
|
|
for y0,y1 in ((26,29),(76,81)):
|
|
scene.box(70,1100,y0,y1,-62,-59.75,162)
|
|
for x in range(84,640,28):
|
|
y = (y0+y1)/2
|
|
if min(abs(x-b) for b in city["river_bridges"]) < 6:
|
|
continue
|
|
scene.cylinder(x,y,-59.75,-56,.14,162,sides=6)
|
|
scene.ellipsoid(x,y,-55,1.7,1.7,2.1,207,rings=4,sides=8)
|
|
scene.box(x+3,x+5,y-.3,y+.3,-59.75,-59.3,162)
|
|
for x in (156,300,444):
|
|
scene.box(x,x+7,28,34,-62,-60.3,196)
|
|
for x,y in city["parks"]:
|
|
scene.horizontal(x-12,x+12,y-12,y+12,-59.9,207)
|
|
scene.horizontal(x-1,x+1,y-12,y+12,-59.8,162)
|
|
for dx in (-8,8):
|
|
for dy in (-8,0,8):
|
|
scene.cylinder(x+dx,y+dy,-60,-56,.15,162,sides=8)
|
|
scene.ellipsoid(x+dx,y+dy,-55.5,2.0,2.0,2.4,207,rings=5,sides=10)
|
|
for x,y in city.get("lamps",[]):
|
|
scene.cylinder(x,y,-60,-55,.055,165,sides=6)
|
|
scene.box(x-.22,x+.22,y-.22,y+.22,-55.15,-54.9,178)
|
|
# A second ground pass multiplies the baked, geometry-derived light map.
|
|
for a,b,c,d,water in patches(-512,512):
|
|
uv = [((a+512)/1024,(d+512)/1024),((b+512)/1024,(d+512)/1024),
|
|
((b+512)/1024,(c+512)/1024),((a+512)/1024,(c+512)/1024)]
|
|
scene.horizontal(a,b,c,d,-61.94 if water else -59.94,208,uv)
|
|
scene.horizontal(a,b,c,d,-61.93 if water else -59.93,209,uv)
|
|
car = Mesh()
|
|
car.box(-1.9,1.9,-.8,.8,.25,.85,196)
|
|
car.box(-.9,.9,-.65,.65,.85,1.35,200)
|
|
for y in (-.57,.57):
|
|
car.box(1.88,1.93,y-.12,y+.12,.5,.68,178)
|
|
for x in (-1.2,1.2):
|
|
for y in (-.83,.83):
|
|
car.ellipsoid(x,y,.3,.32,.12,.32,197,rings=5,sides=10)
|
|
boat = Mesh()
|
|
boat.ellipsoid(0,0,-1.6,3,1,.5,196,rings=6,sides=16)
|
|
boat.box(-1,1,-.6,.6,-1.4,-.7,168)
|
|
counts = {"scene":scene.save("mesh-city-v1.bin",static=True),"car":car.save("mesh-car-v1.bin"),
|
|
"boat":boat.save("mesh-boat-v1.bin")}
|
|
(ART/"mesh-v1.json").write_text(json.dumps({"format":"RGM1: count/bytes + material/3*(position,normal,uv)",
|
|
"gl_axes":"X/Z/Y; normals explicitly outward; no runtime model engine", "triangles":counts,
|
|
"road_rectangles":roads,"river_patches":[p for p in patches(-2000,2000) if p[-1]],
|
|
"removed_invisible_triangles":scene.removed,"front_face":"CCW"},indent=2)+"\n")
|
|
print("Packed native GL meshes:",counts)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|