diff options
| author | Valentin Popov <valentin@popov.link> | 2026-02-12 13:24:42 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-02-12 13:24:42 +0300 |
| commit | 8bf3b7b20961b8bbe47ef8be1046fb2dab689061 (patch) | |
| tree | 43f8d8b354fd022cd1239060acab515a6ed61261 /tools/terrain_map_preview_renderer.py | |
| parent | 669fb40a70fb975f75fa986921b9daaac1f14a0c (diff) | |
| download | fparkan-8bf3b7b20961b8bbe47ef8be1046fb2dab689061.tar.xz fparkan-8bf3b7b20961b8bbe47ef8be1046fb2dab689061.zip | |
feat: добавить 3D рендерер для террейна с поддержкой Land.msh и Land.map
Diffstat (limited to 'tools/terrain_map_preview_renderer.py')
| -rw-r--r-- | tools/terrain_map_preview_renderer.py | 523 |
1 files changed, 523 insertions, 0 deletions
diff --git a/tools/terrain_map_preview_renderer.py b/tools/terrain_map_preview_renderer.py new file mode 100644 index 0000000..d809208 --- /dev/null +++ b/tools/terrain_map_preview_renderer.py @@ -0,0 +1,523 @@ +#!/usr/bin/env python3 +""" +Software 3D renderer for terrain Land.msh + Land.map overlay. + +Output format: binary PPM (P6), dependency-free. +""" + +from __future__ import annotations + +import argparse +import math +import struct +from pathlib import Path +from typing import Any + +import archive_roundtrip_validator as arv + +MAGIC_NRES = b"NRes" + + +def _entry_payload(blob: bytes, entry: dict[str, Any]) -> bytes: + start = int(entry["data_offset"]) + end = start + int(entry["size"]) + return blob[start:end] + + +def _parse_nres(blob: bytes, source: str) -> dict[str, Any]: + if blob[:4] != MAGIC_NRES: + raise RuntimeError(f"{source}: not an NRes payload") + return arv.parse_nres(blob, source=source) + + +def _by_type(entries: list[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]: + out: dict[int, list[dict[str, Any]]] = {} + for row in entries: + out.setdefault(int(row["type_id"]), []).append(row) + return out + + +def _get_single(by_type: dict[int, list[dict[str, Any]]], type_id: int, label: str) -> dict[str, Any]: + rows = by_type.get(type_id, []) + if not rows: + raise RuntimeError(f"missing resource type {type_id} ({label})") + return rows[0] + + +def _downsample_faces( + faces: list[tuple[int, int, int]], + max_faces: int, +) -> list[tuple[int, int, int]]: + if max_faces <= 0 or len(faces) <= max_faces: + return faces + step = len(faces) / max_faces + out: list[tuple[int, int, int]] = [] + pos = 0.0 + while len(out) < max_faces and int(pos) < len(faces): + out.append(faces[int(pos)]) + pos += step + return out + + +def load_terrain_msh( + path: Path, + *, + max_faces: int, +) -> tuple[list[tuple[float, float, float]], list[tuple[int, int, int]], dict[str, int]]: + blob = path.read_bytes() + parsed = _parse_nres(blob, str(path)) + by_type = _by_type(parsed["entries"]) + + res3 = _get_single(by_type, 3, "positions") + res21 = _get_single(by_type, 21, "terrain faces") + + pos_blob = _entry_payload(blob, res3) + if len(pos_blob) % 12 != 0: + raise RuntimeError(f"{path}: type 3 payload size is not divisible by 12") + vertex_count = len(pos_blob) // 12 + positions = [struct.unpack_from("<3f", pos_blob, i * 12) for i in range(vertex_count)] + + face_blob = _entry_payload(blob, res21) + if len(face_blob) % 28 != 0: + raise RuntimeError(f"{path}: type 21 payload size is not divisible by 28") + all_faces: list[tuple[int, int, int]] = [] + raw_face_count = len(face_blob) // 28 + dropped = 0 + for i in range(raw_face_count): + off = i * 28 + i0, i1, i2 = struct.unpack_from("<HHH", face_blob, off + 8) + if i0 >= vertex_count or i1 >= vertex_count or i2 >= vertex_count: + dropped += 1 + continue + all_faces.append((i0, i1, i2)) + + faces = _downsample_faces(all_faces, max_faces) + meta = { + "vertex_count": vertex_count, + "face_count_raw": raw_face_count, + "face_count_valid": len(all_faces), + "face_count_rendered": len(faces), + "face_dropped_invalid": dropped, + } + return positions, faces, meta + + +def load_areal_map(path: Path) -> tuple[list[dict[str, Any]], dict[str, int]]: + blob = path.read_bytes() + parsed = _parse_nres(blob, str(path)) + by_type = _by_type(parsed["entries"]) + chunk = _get_single(by_type, 12, "ArealMapGeometry") + + payload = _entry_payload(blob, chunk) + areal_count = int(chunk["attr1"]) + ptr = 0 + areals: list[dict[str, Any]] = [] + for idx in range(areal_count): + if ptr + 56 > len(payload): + raise RuntimeError(f"{path}: truncated areal header at index={idx}") + class_id = struct.unpack_from("<I", payload, ptr + 40)[0] + vertex_count, poly_count = struct.unpack_from("<II", payload, ptr + 48) + verts_off = ptr + 56 + verts_size = 12 * vertex_count + if verts_off + verts_size > len(payload): + raise RuntimeError(f"{path}: areal[{idx}] vertices out of bounds") + verts = [struct.unpack_from("<3f", payload, verts_off + 12 * i) for i in range(vertex_count)] + + links_off = verts_off + verts_size + links_size = 8 * (vertex_count + 3 * poly_count) + p = links_off + links_size + for _ in range(poly_count): + if p + 4 > len(payload): + raise RuntimeError(f"{path}: areal[{idx}] poly header out of bounds") + n = struct.unpack_from("<I", payload, p)[0] + p += 4 * (3 * n + 1) + if p > len(payload): + raise RuntimeError(f"{path}: areal[{idx}] poly data out of bounds") + + areals.append( + { + "index": idx, + "class_id": class_id, + "vertices": verts, + } + ) + ptr = p + + if ptr + 8 > len(payload): + raise RuntimeError(f"{path}: missing cells section") + cells_x, cells_y = struct.unpack_from("<II", payload, ptr) + ptr += 8 + for _x in range(cells_x): + for _y in range(cells_y): + if ptr + 2 > len(payload): + raise RuntimeError(f"{path}: cells section truncated") + hit_count = struct.unpack_from("<H", payload, ptr)[0] + ptr += 2 + 2 * hit_count + if ptr > len(payload): + raise RuntimeError(f"{path}: cells section out of bounds") + if ptr != len(payload): + raise RuntimeError(f"{path}: trailing bytes in chunk12 parse ({len(payload) - ptr})") + + meta = { + "areal_count": areal_count, + "cells_x": cells_x, + "cells_y": cells_y, + } + return areals, meta + + +def _color_for_class(class_id: int) -> tuple[int, int, int]: + x = (class_id * 1103515245 + 12345) & 0x7FFFFFFF + r = 60 + (x & 0x7F) + g = 60 + ((x >> 7) & 0x7F) + b = 60 + ((x >> 14) & 0x7F) + return r, g, b + + +def _write_ppm(path: Path, width: int, height: int, rgb: bytearray) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("wb") as handle: + handle.write(f"P6\n{width} {height}\n255\n".encode("ascii")) + handle.write(rgb) + + +def _render_scene( + terrain_positions: list[tuple[float, float, float]], + terrain_faces: list[tuple[int, int, int]], + areals: list[dict[str, Any]], + *, + width: int, + height: int, + yaw_deg: float, + pitch_deg: float, + wireframe: bool, + areal_overlay: bool, +) -> bytearray: + all_positions = list(terrain_positions) + if areal_overlay: + for area in areals: + all_positions.extend(area["vertices"]) + if not all_positions: + raise RuntimeError("scene is empty") + + xs = [p[0] for p in all_positions] + ys = [p[1] for p in all_positions] + zs = [p[2] for p in all_positions] + cx = (min(xs) + max(xs)) * 0.5 + cy = (min(ys) + max(ys)) * 0.5 + cz = (min(zs) + max(zs)) * 0.5 + span = max(max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs)) + radius = max(span * 0.5, 1e-3) + + yaw = math.radians(yaw_deg) + pitch = math.radians(pitch_deg) + cyaw = math.cos(yaw) + syaw = math.sin(yaw) + cpitch = math.cos(pitch) + spitch = math.sin(pitch) + camera_dist = radius * 3.2 + scale = min(width, height) * 0.96 + + # Terrain transform cache. + vx: list[float] = [] + vy: list[float] = [] + vz: list[float] = [] + sx: list[float] = [] + sy: list[float] = [] + for x, y, z in terrain_positions: + x0 = x - cx + y0 = y - cy + z0 = z - cz + x1 = cyaw * x0 + syaw * z0 + z1 = -syaw * x0 + cyaw * z0 + y2 = cpitch * y0 - spitch * z1 + z2 = spitch * y0 + cpitch * z1 + camera_dist + if z2 < 1e-3: + z2 = 1e-3 + vx.append(x1) + vy.append(y2) + vz.append(z2) + sx.append(width * 0.5 + (x1 / z2) * scale) + sy.append(height * 0.5 - (y2 / z2) * scale) + + def project_point(x: float, y: float, z: float) -> tuple[float, float, float]: + x0 = x - cx + y0 = y - cy + z0 = z - cz + x1 = cyaw * x0 + syaw * z0 + z1 = -syaw * x0 + cyaw * z0 + y2 = cpitch * y0 - spitch * z1 + z2 = spitch * y0 + cpitch * z1 + camera_dist + if z2 < 1e-3: + z2 = 1e-3 + px = width * 0.5 + (x1 / z2) * scale + py = height * 0.5 - (y2 / z2) * scale + return px, py, z2 + + rgb = bytearray([14, 16, 20] * (width * height)) + zbuf = [float("inf")] * (width * height) + light_dir = (0.35, 0.45, 1.0) + l_len = math.sqrt(light_dir[0] ** 2 + light_dir[1] ** 2 + light_dir[2] ** 2) + light = (light_dir[0] / l_len, light_dir[1] / l_len, light_dir[2] / l_len) + + def edge(ax: float, ay: float, bx: float, by: float, px: float, py: float) -> float: + return (px - ax) * (by - ay) - (py - ay) * (bx - ax) + + for i0, i1, i2 in terrain_faces: + x0 = sx[i0] + y0 = sy[i0] + x1 = sx[i1] + y1 = sy[i1] + x2 = sx[i2] + y2 = sy[i2] + area = edge(x0, y0, x1, y1, x2, y2) + if area == 0.0: + continue + + ux = vx[i1] - vx[i0] + uy = vy[i1] - vy[i0] + uz = vz[i1] - vz[i0] + wx = vx[i2] - vx[i0] + wy = vy[i2] - vy[i0] + wz = vz[i2] - vz[i0] + nx = uy * wz - uz * wy + ny = uz * wx - ux * wz + nz = ux * wy - uy * wx + n_len = math.sqrt(nx * nx + ny * ny + nz * nz) + if n_len > 0.0: + nx /= n_len + ny /= n_len + nz /= n_len + intensity = nx * light[0] + ny * light[1] + nz * light[2] + if intensity < 0.0: + intensity = 0.0 + shade = int(45 + 185 * intensity) + color = (min(255, shade + 6), min(255, shade + 14), min(255, shade + 28)) + + minx = int(max(0, math.floor(min(x0, x1, x2)))) + maxx = int(min(width - 1, math.ceil(max(x0, x1, x2)))) + miny = int(max(0, math.floor(min(y0, y1, y2)))) + maxy = int(min(height - 1, math.ceil(max(y0, y1, y2)))) + if minx > maxx or miny > maxy: + continue + + z0 = vz[i0] + z1 = vz[i1] + z2 = vz[i2] + inv_area = 1.0 / area + for py in range(miny, maxy + 1): + fy = py + 0.5 + row = py * width + for px in range(minx, maxx + 1): + fx = px + 0.5 + w0 = edge(x1, y1, x2, y2, fx, fy) + w1 = edge(x2, y2, x0, y0, fx, fy) + w2 = edge(x0, y0, x1, y1, fx, fy) + if area > 0: + if w0 < 0 or w1 < 0 or w2 < 0: + continue + else: + if w0 > 0 or w1 > 0 or w2 > 0: + continue + bz0 = w0 * inv_area + bz1 = w1 * inv_area + bz2 = w2 * inv_area + depth = bz0 * z0 + bz1 * z1 + bz2 * z2 + idx = row + px + if depth >= zbuf[idx]: + continue + zbuf[idx] = depth + p = idx * 3 + rgb[p + 0] = color[0] + rgb[p + 1] = color[1] + rgb[p + 2] = color[2] + + def draw_line( + xa: float, + ya: float, + xb: float, + yb: float, + color: tuple[int, int, int], + ) -> None: + x0i = int(round(xa)) + y0i = int(round(ya)) + x1i = int(round(xb)) + y1i = int(round(yb)) + dx = abs(x1i - x0i) + sx_step = 1 if x0i < x1i else -1 + dy = -abs(y1i - y0i) + sy_step = 1 if y0i < y1i else -1 + err = dx + dy + x = x0i + y = y0i + while True: + if 0 <= x < width and 0 <= y < height: + p = (y * width + x) * 3 + rgb[p + 0] = color[0] + rgb[p + 1] = color[1] + rgb[p + 2] = color[2] + if x == x1i and y == y1i: + break + e2 = 2 * err + if e2 >= dy: + err += dy + x += sx_step + if e2 <= dx: + err += dx + y += sy_step + + if wireframe: + wf = (225, 232, 246) + for i0, i1, i2 in terrain_faces: + draw_line(sx[i0], sy[i0], sx[i1], sy[i1], wf) + draw_line(sx[i1], sy[i1], sx[i2], sy[i2], wf) + draw_line(sx[i2], sy[i2], sx[i0], sy[i0], wf) + + if areal_overlay: + for area in areals: + verts = area["vertices"] + if len(verts) < 2: + continue + color = _color_for_class(int(area["class_id"])) + projected = [project_point(x, y, z + 0.35) for x, y, z in verts] + for i in range(len(projected)): + x0, y0, _ = projected[i] + x1, y1, _ = projected[(i + 1) % len(projected)] + draw_line(x0, y0, x1, y1, color) + + return rgb + + +def cmd_render(args: argparse.Namespace) -> int: + msh_path = Path(args.land_msh).resolve() + map_path = Path(args.land_map).resolve() if args.land_map else None + output_path = Path(args.output).resolve() + + positions, faces, terrain_meta = load_terrain_msh(msh_path, max_faces=int(args.max_faces)) + areals: list[dict[str, Any]] = [] + map_meta: dict[str, int] = {"areal_count": 0, "cells_x": 0, "cells_y": 0} + if map_path: + areals, map_meta = load_areal_map(map_path) + + rgb = _render_scene( + positions, + faces, + areals, + width=int(args.width), + height=int(args.height), + yaw_deg=float(args.yaw), + pitch_deg=float(args.pitch), + wireframe=bool(args.wireframe), + areal_overlay=bool(args.overlay_areals), + ) + _write_ppm(output_path, int(args.width), int(args.height), rgb) + + print(f"Rendered terrain : {msh_path}") + if map_path: + print(f"Areal overlay : {map_path}") + print(f"Output : {output_path}") + print( + "Terrain geometry : " + f"vertices={terrain_meta['vertex_count']}, " + f"faces={terrain_meta['face_count_rendered']}/{terrain_meta['face_count_valid']} " + f"(raw={terrain_meta['face_count_raw']}, dropped={terrain_meta['face_dropped_invalid']})" + ) + if map_path: + print( + "Areal map : " + f"areals={map_meta['areal_count']}, cells={map_meta['cells_x']}x{map_meta['cells_y']}" + ) + return 0 + + +def cmd_render_batch(args: argparse.Namespace) -> int: + maps_root = Path(args.maps_root).resolve() + output_dir = Path(args.output_dir).resolve() + msh_paths = sorted(maps_root.rglob("Land.msh")) + if not msh_paths: + raise RuntimeError(f"no Land.msh files under {maps_root}") + + rendered = 0 + skipped = 0 + for msh_path in msh_paths: + map_path = msh_path.with_name("Land.map") + if not map_path.exists(): + skipped += 1 + continue + rel = msh_path.parent.relative_to(maps_root) + out = output_dir / f"{rel.as_posix().replace('/', '__')}.ppm" + cmd_render( + argparse.Namespace( + land_msh=str(msh_path), + land_map=str(map_path), + output=str(out), + max_faces=args.max_faces, + width=args.width, + height=args.height, + yaw=args.yaw, + pitch=args.pitch, + wireframe=args.wireframe, + overlay_areals=args.overlay_areals, + ) + ) + rendered += 1 + + print(f"Batch summary: rendered={rendered}, skipped_no_map={skipped}, output_dir={output_dir}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Software 3D terrain renderer (Land.msh + optional Land.map overlay)." + ) + sub = parser.add_subparsers(dest="command", required=True) + + render = sub.add_parser("render", help="Render one terrain map to PPM.") + render.add_argument("--land-msh", required=True, help="Path to Land.msh") + render.add_argument("--land-map", help="Path to Land.map (optional)") + render.add_argument("--output", required=True, help="Output .ppm path") + render.add_argument("--max-faces", type=int, default=220000, help="Face limit (default: 220000)") + render.add_argument("--width", type=int, default=1280, help="Image width (default: 1280)") + render.add_argument("--height", type=int, default=720, help="Image height (default: 720)") + render.add_argument("--yaw", type=float, default=38.0, help="Yaw angle in degrees (default: 38)") + render.add_argument("--pitch", type=float, default=26.0, help="Pitch angle in degrees (default: 26)") + render.add_argument("--wireframe", action="store_true", help="Draw terrain wireframe overlay") + render.add_argument( + "--overlay-areals", + action="store_true", + help="Draw ArealMap polygon overlay", + ) + render.set_defaults(func=cmd_render) + + batch = sub.add_parser("render-batch", help="Render all MAPS/**/Land.msh under root.") + batch.add_argument( + "--maps-root", + default="tmp/gamedata/DATA/MAPS", + help="Root directory with MAPS subfolders (default: tmp/gamedata/DATA/MAPS)", + ) + batch.add_argument("--output-dir", required=True, help="Directory for output PPM files") + batch.add_argument("--max-faces", type=int, default=90000, help="Face limit per map (default: 90000)") + batch.add_argument("--width", type=int, default=960, help="Image width (default: 960)") + batch.add_argument("--height", type=int, default=540, help="Image height (default: 540)") + batch.add_argument("--yaw", type=float, default=38.0, help="Yaw angle in degrees (default: 38)") + batch.add_argument("--pitch", type=float, default=26.0, help="Pitch angle in degrees (default: 26)") + batch.add_argument("--wireframe", action="store_true", help="Draw terrain wireframe overlay") + batch.add_argument( + "--overlay-areals", + action="store_true", + help="Draw ArealMap polygon overlay", + ) + batch.set_defaults(func=cmd_render_batch) + + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) + |
