KAZIK/tools/generators/pack_rooftop_gl.py
Artvasil09 41ba8f46ed feat: add rooftop city and optimize native terrace rendering
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.
2026-10-26 23:20:35 +03:00

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()