KAZIK/tools/generators/pack_rooftop.py
Artvasil09 1006ba444b fix: restore rooftop ground on Intel and fill canal frontage
Tessellate terrain and light-map surfaces for vertex-fog drivers, render animated water from the tiled mesh, request accurate fog and extend renderer diagnostics. Add canal-front buildings, raised quays, trees and benches while preserving the terrace and native runtime.

Update native regression coverage and shipping executables. Exclude laptop screenshots and local runtime data.
2026-10-27 19:24:00 +03:00

419 lines
21 KiB
Python

"""Offline rooftop atlas/map compiler. Game rendering and travel remain MASM."""
from pathlib import Path
import json
import math
import struct
import numpy as np
import cv2
from PIL import Image
ROOT = Path(__file__).resolve().parents[2]
ART = ROOT / "assets/sprites/rooftop"
OUT = ROOT / "assets/animations/rooftop"
def grid(path, columns, rows):
source = Image.open(ART/path).convert("RGBA")
return [source.crop((round(x*source.width/columns), round(y*source.height/rows),
round((x+1)*source.width/columns), round((y+1)*source.height/rows)))
.resize((256, 256), Image.Resampling.LANCZOS)
for y in range(rows) for x in range(columns)]
def pack(name, frames):
w, h = frames[0].size
payload = b"".join(frame.tobytes("raw", "BGRA") for frame in frames)
(OUT/name).write_bytes(struct.pack("<4s4I", b"ANM1", w, h, len(frames), len(payload))+payload)
def clean_hands(frame):
pixels = np.asarray(frame).copy()
mask = (pixels[:,:,3] >= 128).astype(np.uint8)
mask = cv2.morphologyEx(mask,cv2.MORPH_OPEN,np.ones((3,3),np.uint8))
count,labels,stats,_ = cv2.connectedComponentsWithStats(mask)
keep = [i for i in range(1,count) if stats[i,cv2.CC_STAT_AREA] > 2500]
support = np.isin(labels,keep).astype(np.uint8)
support = cv2.erode(support,np.ones((3,3),np.uint8))
pixels[:,:,3] = support*255
pixels[support == 0,:3] = 0
return Image.fromarray(pixels)
def night_facade(frame, seed):
"""Identify window apertures; mark lit glass with FF alpha and unlit walls FE."""
pixels = np.asarray(frame).copy()
rgb = pixels[:,:,:3].astype(np.int16)
mask = ((rgb.max(axis=2)<145) & (rgb[:,:,2]>rgb[:,:,0]-10)).astype(np.uint8)*255
mask = cv2.morphologyEx(mask,cv2.MORPH_CLOSE,np.ones((7,5),np.uint8))
contours,_ = cv2.findContours(mask,cv2.RETR_EXTERNAL,cv2.CHAIN_APPROX_SIMPLE)
pixels[:,:,3] = 254
rng = np.random.default_rng(seed)
count = 0
for contour in contours:
x,y,w,h = cv2.boundingRect(contour)
if not (8 <= w <= 110 and 14 <= h <= 180 and .8 <= h/w <= 4):
continue
if rng.random() > .48:
continue
region = pixels[y+2:y+h-2,x+2:x+w-2]
source = region[:,:,:3].astype(np.int16)
glass = (source[:,:,2]>source[:,:,0]+4) & (source.max(axis=2)<155)
if glass.sum() < 15:
continue
yy,xx = np.mgrid[:h-4,:w-4]
glow = np.clip(1-((xx-(w-4)/2)/(w/2))**2*.22, .5, 1)
warmth = np.array([210,148,76])*rng.uniform(.7,1.1)
lit = np.clip(glow[:,:,None]*warmth,0,255).astype(np.uint8)
region[:,:,:3][glass] = lit[glass]
region[:,:,3][glass] = 255
count += 1
return Image.fromarray(pixels),count
def main():
OUT.mkdir(parents=True, exist_ok=True)
sky = Image.open(ART/"sunset-sky-v1.png").convert("RGBA").resize((2048,512),Image.Resampling.LANCZOS)
# A short symmetric overlap makes the cylindrical wrap continuous.
original = sky.copy()
for x in range(32):
mixed = Image.blend(original.crop((x,0,x+1,512)),
original.crop((2047-x,0,2048-x,512)), (32-x)/64)
sky.paste(mixed, (x,0))
mixed = Image.blend(original.crop((2047-x,0,2048-x,512)),
original.crop((x,0,x+1,512)), (32-x)/64)
sky.paste(mixed, (2047-x,0))
pack("panorama-v1.bin", [sky.crop((i*512,0,(i+1)*512,512)) for i in range(4)])
night = Image.open(ART/"night-sky-v1.png").convert("RGBA").resize((2048,512),Image.Resampling.LANCZOS)
pack("night-v1.bin",[night.crop((i*512,0,(i+1)*512,512)) for i in range(4)])
hands = Image.open(ART/"rail-hands-v1.png").convert("RGBA")
poses = [hands.crop((round(x*hands.width/4),round(y*hands.height/2),
round((x+1)*hands.width/4),round((y+1)*hands.height/2)))
.resize((640,320),Image.Resampling.LANCZOS)
for y in range(2) for x in range(4)]
# Offline bidirectional flow supplies in-between poses; playback is pure MASM.
coordinates = np.mgrid[:320,:640][::-1].transpose(1,2,0).astype(np.float32)
smooth = []
for first,second in zip(poses,poses[1:]):
a,b = np.asarray(first),np.asarray(second)
ag = cv2.cvtColor(a,cv2.COLOR_RGBA2GRAY)
bg = cv2.cvtColor(b,cv2.COLOR_RGBA2GRAY)
forward = cv2.calcOpticalFlowFarneback(ag,bg,None,.5,5,35,6,7,1.5,0)
backward = cv2.calcOpticalFlowFarneback(bg,ag,None,.5,5,35,6,7,1.5,0)
for index in range(4):
q = index/4
aw = cv2.remap(a,coordinates-forward*q,None,cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT)
bw = cv2.remap(b,coordinates-backward*(1-q),None,cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT)
smooth.append(Image.fromarray(cv2.addWeighted(aw,1-q,bw,q,0)))
smooth += [poses[-1]]*4
assert len(smooth) == 32
pack("rail-hands-v1.bin",[clean_hands(frame) for frame in smooth])
materials = grid("materials-v1.png",4,2)
pack("materials-v1.bin",materials)
panels = grid("panels-v1.png",4,2)[:4]
# One moving leaf, not a photograph of a whole elevator on each leaf.
panels[0] = panels[0].crop((72,16,126,235)).resize((256,256),Image.Resampling.LANCZOS)
# Use only the fluted facade, not the reference's pictured skyline above it.
panels[2] = panels[2].crop((0,116,256,240)).resize((256,256),Image.Resampling.LANCZOS)
cutouts = grid("cutouts-v1.png",2,2)
for frame in cutouts:
assert frame.getchannel("A").getextrema()[0] == 0, "cutouts require actual alpha"
sofas = Image.open(ART/"sofa-views-v1.png").convert("RGBA")
occupied = np.sum(np.asarray(sofas.getchannel("A")) > 128,axis=0) > 10
edges = np.flatnonzero(np.diff(np.r_[False,occupied,False]))
spans = [(int(a),int(b)) for a,b in zip(edges[::2],edges[1::2]) if b-a > 80]
assert len(spans) == 3, spans
boxes = []
for lo,hi in spans:
box = sofas.crop((lo,0,hi,sofas.height)).getchannel("A").point(lambda a:255 if a>128 else 0).getbbox()
boxes.append((lo,box[1],hi,box[3]))
top = min(box[1] for box in boxes)
bottom = max(box[3] for box in boxes)
views = [sofas.crop((box[0],top,box[2],bottom)).resize((256,256),Image.Resampling.LANCZOS)
for box in boxes]
chairs = Image.open(ART/"chair-views-v1.png").convert("RGBA")
split = round(chairs.width*.55)
boxes = [(0,0,split,chairs.height//2),(split,0,chairs.width,chairs.height//2),
(0,chairs.height//2,split,chairs.height),(split,chairs.height//2,chairs.width,chairs.height)]
chair_views = []
for box in boxes:
frame = chairs.crop(box)
tight = frame.getchannel("A").point(lambda a:255 if a>128 else 0).getbbox()
chair_views.append(frame.crop(tight).resize((256,256),Image.Resampling.LANCZOS))
pack("details-v1.bin",panels+cutouts+views+chair_views)
facade = Image.open(ART/"city-facades-v1.png").convert("RGBA")
city = [facade.crop((round(x*facade.width/4),round(y*facade.height/2),
round((x+1)*facade.width/4),round((y+1)*facade.height/2)))
.resize((512,512),Image.Resampling.LANCZOS)
for y in range(2) for x in range(4)]
# Foliage-only tuft, not a planter photographed on a vertical card.
city.append(cutouts[1].crop((0,0,256,160)).resize((512,512),Image.Resampling.LANCZOS))
earth = np.asarray(materials[1].resize((512,512))).copy()
earth[:,:,:3] = (earth[:,:,:3].astype(np.uint16)*np.array([37,31,24])//100).astype(np.uint8)
city.append(Image.fromarray(earth))
city.append(Image.open(ART/"roof-zinc-v1.png").convert("RGBA").resize((512,512),Image.Resampling.LANCZOS))
# Existing high-detail stone is also a road aggregate; water is a generated
# native texture below. Neither is a photograph of a whole city object.
road = np.asarray(materials[1].resize((512,512))).copy()
road[:,:,:3] = (road[:,:,:3].astype(np.uint16)*45//100).astype(np.uint8)
city.append(Image.fromarray(road))
yy,xx = np.mgrid[:512,:512]
ripple = (np.sin(xx*.17+np.sin(yy*.09)*2)+np.sin(yy*.31))*4
water = np.zeros((512,512,4),dtype=np.uint8)
for channel,base in enumerate((24,55,72)):
water[:,:,channel] = np.clip(base+ripple,0,255).astype(np.uint8)
water[:,:,3] = 255
city.append(Image.fromarray(water))
modern = Image.open(ART/"modern-facades-v1.png").convert("RGBA")
city += [modern.crop((round(x*modern.width/4),round(y*modern.height/2),
round((x+1)*modern.width/4),round((y+1)*modern.height/2)))
.resize((512,512),Image.Resampling.LANCZOS)
for y in range(2) for x in range(4)]
noise = np.random.default_rng(701).integers(-8,9,size=(512,512,1))
lawn = np.empty((512,512,4),dtype=np.uint8)
lawn[:,:,:3] = np.clip(np.array([39,68,43])+noise,0,255)
lawn[:,:,3] = 255
city.append(Image.fromarray(lawn))
pack("city-v1.bin",city)
night_city,lit_windows = [],0
for index,frame in enumerate(city):
if index < 8 or 13 <= index < 21:
frame,count = night_facade(frame,2000+index)
lit_windows += count
night_city.append(frame)
pack("city-night-v1.bin",night_city)
count = pack_city_district()
pack_roof_map()
print(f"Packed roof: sunset/night, {lit_windows} distinct lit window apertures, 32 hand frames, {count} buildings.")
def pack_city_district():
# A connected street/courtyard district, not a ring of independent props.
blocks = []
lots,parks = [],[]
for gy in range(-5,6):
for gx in range(-5,6):
x,y = 20+gx*36,11+gy*36
if gx == 0 and gy == 0:
continue
# The cross-city canal has continuous quays, not houses over water.
if y+14 > 54 and y-14 < 82:
continue
# Two open squares reserve space around the dome and golden spire.
if (gx,gy) in ((0,3),(4,0)):
continue
key = (gx*17+gy*31)%37
if key%11 == 0 or (gx,gy) in ((1,0),(-1,0)):
parks.append((x,y))
continue
facade = 186+(gx*3+gy)%8
if max(abs(gx),abs(gy)) <= 1:
top = -60+6+key%6
facade = 203
wings = [(x-6,x+5,y-5,y+5)]
elif key%7 == 0:
top = -60+24+key%17
facade = 199+(key%2)*5
wings = [(x-8,x+8,y-10,y+10)]
else:
top = -60+12+key%16
wings = [(x-14,x+14,y-14,y-7),(x-14,x+14,y+7,y+14),
(x-14,x-7,y-7,y+7),(x+7,x+14,y-7,y+7)]
if key%3 == 0:
wings = wings[:3]
elif key%5 == 0:
wings = wings[1:3]
lots.append({"centre":[x,y],"courtyard":[x-7,x+7,y-7,y+7],
"facade":facade,"height":top+60})
for index,wing in enumerate(wings):
blocks.append((*wing,top+(index%2)*.3,facade,index))
# Distant quarters are aggregated geometry, not a scaled city photograph.
for gy in range(-8,9):
for gx in range(-8,9):
if max(abs(gx),abs(gy)) <= 3:
continue
x,y = 20+gx*64,11+gy*64
if y+24 > 54 and y-24 < 82:
continue
key = (gx*5+gy*11)%29
top = -60+10+key
half = 17+key%10
blocks.append((x-half,x+half,y-24,y+24,top,186+(gx+gy)%8,4))
towers = [(320,-130,90),(355,-165,126),(390,-125,108),(425,-195,155),
(460,-145,102),(345,-225,115),(405,-260,132),(475,-240,88)]
for index,(x,y,height) in enumerate(towers):
blocks.append((x-8,x+8,y-10,y+10,-60+height,200+index%3,2))
blocks.append((x-5,x+5,y-7,y+7,-60+height+7,200+index%3,2))
# The eastern avenue opens into a broad river, not an endless paved corridor.
blocks = [b for b in blocks if not (b[1] > 68 and b[2] < 81 and b[3] > 26)]
# Far riverfront quarters break the empty, perfectly straight horizon.
for side in (-1,1):
for i in range(10):
x = 570+i*34
y = 6-(i%3)*8 if side < 0 else 106+(i%4)*9
h = 16+(i*7)%24
blocks.append((x-11,x+11,y-10,y+10,-60+h,186+i%8,4))
# Canal clearance removes water footprints, not entire empty city blocks.
waterfront = []
for gx in range(-13,2):
x = 20+gx*36
for bank in (0,1):
key = (gx*19+bank*7)%31
front,back = (53,39-key%4) if bank == 0 else (82,94+key%3)
y0,y1 = sorted((front,back))
mid = x-2+key%5
for index,(x0,x1) in enumerate(((x-13,mid-.45),(mid+.45,x+13))):
top = -60+12+(key+index*3)%11
waterfront.append((x0,x1,y0,y1,top,186+(gx+bank*3+index)%8,5))
blocks.extend(waterfront)
blocks.sort(key=lambda b:(((b[0]+b[1])/2-20)**2+((b[2]+b[3])/2-11)**2))
# Ground-only baked illumination follows the same real building footprints.
elevation = np.zeros((512,512),dtype=np.float32)
for x0,x1,y0,y1,z,_,_ in blocks:
loX,hiX = max(0,math.floor(x0/2)+256),min(512,math.ceil(x1/2)+256)
loY,hiY = max(0,math.floor(y0/2)+256),min(512,math.ceil(y1/2)+256)
if loX < hiX and loY < hiY:
elevation[loY:hiY,loX:hiX] = np.maximum(elevation[loY:hiY,loX:hiX],z+60)
occlusion = np.zeros_like(elevation)
padded = np.pad(elevation,128)
for step in range(1,100):
sx,sy = round(-step*.89),round(step*.45)
ray_height = step*2*math.tan(math.radians(18))
blocked = padded[128+sy:640+sy,128+sx:640+sx]-ray_height
occlusion = np.maximum(occlusion,blocked)
shadow = cv2.GaussianBlur(np.where(occlusion>0,155,250).astype(np.uint8),(5,5),1)
day = np.empty((512,512,4),dtype=np.uint8)
day[:,:,:3] = shadow[:,:,None]
day[:,:,3] = 255
lamps = []
for gx in range(-5,6):
for gy in range(-6,7):
lamps.append((34.8+36*gx,11+30*gy))
lamps += [(x,y) for x in range(-170,211,18) for y in (58.5,77.5)]
lamps = [(x,y) for x,y in lamps if not (60 < y < 76 or (x > 68 and 29 < y < 76))]
lamps += [(x,y) for x in range(86,650,18) for y in (27,78)]
yy,xx = np.mgrid[:512,:512]
world_x,world_y = (xx-256)*2,(yy-256)*2
illumination = np.zeros((512,512,3),dtype=np.float32)+[24,30,46]
for lx,ly in lamps:
energy = np.exp(-((world_x-lx)**2+(world_y-ly)**2)/(2*5.5**2))
illumination += energy[:,:,None]*np.array([215,150,64])
if ly in (27,78,58.5,77.5):
reflected = np.exp(-((world_x-lx)**2/(2*1.6**2)+(world_y-ly)**2/(2*13**2)))
reflected *= ((world_y>=60)&(world_y<=76)) | ((world_x>=70)&(world_y>=29)&(world_y<=76))
illumination += reflected[:,:,None]*np.array([180,125,60])
night_light = np.empty((512,512,4),dtype=np.uint8)
night_light[:,:,:3] = np.clip(illumination,0,255).astype(np.uint8)
night_light[:,:,3] = 255
glow = np.empty_like(night_light)
glow[:,:,:3] = np.clip((illumination-np.array([24,30,46]))*.38,0,255).astype(np.uint8)
glow[:,:,3] = 255
pack("lighting-v1.bin",[Image.fromarray(day),Image.fromarray(night_light),Image.fromarray(glow)])
district = ["; Generated city block footprints and rooftop-relative heights.",
f"ROOF_BUILDING_COUNT equ {len(blocks)}", ".data", "roofDistrict3D LABEL DWORD"]
for block in blocks:
district.append(" dd "+",".join(str(round(v*65536)) if i<5 else str(v)
for i,v in enumerate(block)))
district += [f"ROOF_PARK_COUNT equ {len(parks)}","roofCityParks3D LABEL DWORD"]
district += [f" dd {round(x*65536)},{round(y*65536)}" for x,y in parks]
district += [f"ROOF_CITY_LAMP_COUNT equ {len(lamps)}","roofCityLamps3D LABEL DWORD"]
district += [f" dd {round(x*65536)},{round(y*65536)}" for x,y in lamps]
pieces = []
def landmark_piece(kind,coords,material,pivot,target,scale=2):
ox,oy = pivot
tx,ty = target
if kind == 0:
a,b,c,d,e,f = coords
coords = ((a-ox)*scale+tx,(b-ox)*scale+tx,(c-oy)*scale+ty,(d-oy)*scale+ty,
(e+17)*scale-60,(f+17)*scale-60)
else:
a,b,c,d,e,f = coords
coords = ((a-ox)*scale+tx,(b-oy)*scale+ty,(c+17)*scale-60,d*scale,e*scale,f*scale)
pieces.append((kind,*[round(v*65536) for v in coords],material))
dome = lambda kind,coords,mat: landmark_piece(kind,coords,mat,(56,47),(20,119))
dome(0,(47,65,38,56,-17,2),193)
dome(0,(50,62,41,53,2,5),168)
for index in range(16):
angle = index*math.tau/16
x,y = 56+5*math.cos(angle),47+5*math.sin(angle)
dome(0,(x,x+.35,y,y+.35,5,8),168)
dome(0,(50,62,41,53,8,8.4),196)
dome(1,(56,47,8.3,5.9,5.9,5),196)
dome(0,(55.4,56.6,46.4,47.6,13,15),166)
dome(0,(55.9,56.1,46.9,47.1,15,18),166)
spire = lambda kind,coords,mat: landmark_piece(kind,coords,mat,(-16,-61),(164,11))
spire(0,(-23,-9,-68,-54,-17,0),191)
spire(0,(-19,-13,-64,-58,0,9),206)
spire(0,(-18,-14,-63,-59,9,15),191)
spire(2,(-16,-61,15,1.8,1.8,20),166)
# The same modest quay geometry is consumed by both native renderers.
quays = []
def quay_piece(kind,coords,mat):
piece = (kind,*[round(v*65536) for v in coords],mat)
pieces.append(piece)
quays.append((kind,*coords,mat))
for y0,y1 in ((55,60),(76,81)):
quay_piece(0,(-520,70,y0,y1,-62,-59.75),162)
edge = 59.65 if y0 < 60 else 76
for a,b in ((-520,-37.6),(-30.4,34.4),(41.6,70)):
quay_piece(0,(a,b,edge,edge+.35,-59.75,-58.85),162)
quay_piece(0,(a,b,edge-.04,edge+.39,-58.85,-58.7),168)
for x in range(-184,68,18):
if min(abs(x-b) for b in (-34,38)) < 6:
continue
for y in (56.7,79.3):
quay_piece(0,(x-.12,x+.12,y-.12,y+.12,-59.75,-56.8),162)
quay_piece(1,(x,y,-56.8,1.5,1.5,2.1),207)
quay_piece(0,(x+3,x+5,y-.25,y+.25,-59.35,-59.15),196)
for leg in (x+3.2,x+4.6):
quay_piece(0,(leg,leg+.18,y-.2,y+.2,-59.75,-59.35),165)
district += [f"ROOF_LANDMARK_PARTS equ {len(pieces)}","roofLandmarkParts3D LABEL DWORD"]
district += [" dd "+",".join(map(str,piece)) for piece in pieces]
fog = [min(190,round(d*180/(d+250))) for d in range(1024)]
for start in range(0,1024,32):
district.append(("roofFog3D" if start == 0 else " ")+" db "+
",".join(map(str,fog[start:start+32])))
district.append(".code")
(ROOT/"src_graphics/rooftop_district_data.inc").write_text("\n".join(district)+"\n",encoding="ascii")
(ART/"district-map-v1.json").write_text(json.dumps({"extent":[-520,560,-530,550],
"casino":[10.9,29.1,.9,21.1],"street_spacing":36,"street_width":8,
"street_level":-60,"canal":[-190,230,60,76],"bridges":[-34,38,110],
"east_river":[70,1800,29,76],"river_bridges":[110,254,398],
"waterfront_buildings":waterfront,"canal_quay_parts":quays,
"lots":lots,"parks":parks,"lamps":lamps,"towers":towers,"buildings":blocks,
"landmarks":{"dome":[20,119],"spire":[164,11]}},indent=2)+"\n",encoding="ascii")
return len(blocks)
def pack_roof_map():
# Collision-only enclosure uses a valid legacy prepass material; the final
# rooftop pass replaces its tall walls with low world-space railings.
roof = [[102]*32 for _ in range(32)]
for y in range(1,21):
for x in range(11,29):
roof[y][x] = 0
lines = ["; Generated by tools/generators/pack_rooftop.py.", ".data"]
for y,row in enumerate(roof):
lines.append(("roofMap3D" if y == 0 else " ")+" db "+",".join(map(str,row)))
columns = [round(math.atan((x/320-1)*.66)*2048/(2*math.pi)) for x in range(640)]
for start in range(0,640,16):
lines.append(("roofSkyColumns3D" if start == 0 else " ")+" dd "+
",".join(map(str,columns[start:start+16])))
(ROOT/"src_graphics/rooftop_map.inc").write_text("\n".join(lines)+"\n",encoding="ascii")
(ART/"packed-v1.json").write_text(json.dumps({"panorama":[512,512,4],
"materials":[256,256,8],"details":[256,256,15],"city":[512,512,22],
"city-night":[512,512,22],"night":[512,512,4],"rail-hands":[640,320,32],
"lighting":[512,512,3],"city_renderer":"world-space MASM geometry; sky only is raster",
"source":"Built-in image generation; runtime geometry and interaction are assembly-only",
"reference":"https://www.loftprojectetagi.ru/visitors/krysha/"},indent=2)+"\n",encoding="ascii")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--district-only",action="store_true")
if parser.parse_args().district_only:
print(f"Packed city district: {pack_city_district()} building parts")
else:
main()