├── .gitignore ├── README.md ├── bsp_format.py ├── clipper.py ├── data ├── example.bsp ├── example.map └── example.prt ├── docs ├── LICENSE └── matplotlib.png ├── portaltypes.py ├── prt_format.py ├── requirements.txt └── vis.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | venv/ 3 | .vscode/ 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # vis.py 3 | 4 | This is tool computes a potentially visible set (PVS) for a Quake map. It converts a portal file (PRT) to a leaf-to-leaf visibility matrix that is written to a map BSP file. It needs [other map tools](http://ericwa.github.io/ericw-tools/) to work. 5 | 6 | I've written a detailed explanation of the algorithm in the [*Demystifying the PVS*](https://30fps.net/pages/pvs-portals-and-quake/) series on my blog. 7 | 8 | ![The portals of a simple example map.](docs/matplotlib.png) 9 | *The rectangular portals of a simple example map.* 10 | 11 | Since this is single-threaded Python code, it runs very slowly :) 12 | 13 | ## Structure 14 | 15 | The main algorithm is in the `vis.py` file. 3D clipping calculations and separating plane selection are in `clipper.py`. Input and output are handled in `prt_format.py` and `bsp_format.py`, respectively. 16 | 17 | ## Installation 18 | 19 | ``` 20 | pip install -r requirements.txt 21 | ``` 22 | 23 | The only real dependency is NumPy. Matplotlib is used for 3D visualization but can be omitted with the `--noviz` commandline flag. 24 | 25 | Tested with Python 3.9.9. 26 | 27 | ## Usage 28 | 29 | ``` 30 | python vis.py data/example.prt data/example.bsp output.bsp 31 | ``` 32 | 33 | ## Credits 34 | 35 | This code is a Python reimplementation of the algorithms in [the original `vis` tool](https://github.com/ericwa/ericw-tools/blob/a6c7a18cb85cef64948d46780d4fc1bb3d1f575b/vis/) and [the updated version in ericw-tools](https://github.com/ericwa/ericw-tools/blob/a6c7a18cb85cef64948d46780d4fc1bb3d1f575b/vis/). 36 | 37 | Some Quake BSP utilities adapted from Matthew Earl's [pyquake](https://github.com/matthewearl/pyquake) library. 38 | 39 | License: [MIT No Attribution (MIT-0)](https://en.wikipedia.org/wiki/MIT_License#MIT_No_Attribution_License). -------------------------------------------------------------------------------- /bsp_format.py: -------------------------------------------------------------------------------- 1 | import struct 2 | import logging 3 | import enum 4 | import numpy as np 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class Q1Lump(enum.Enum): 10 | ENTITIES = 0 11 | PLANES = 1 12 | TEXTURES = 2 13 | VERTEXES = 3 14 | VISIBILITY = 4 15 | NODES = 5 16 | TEXINFO = 6 17 | FACES = 7 18 | LIGHTING = 8 19 | CLIPNODES = 9 20 | LEAFS = 10 21 | MARKSURFACES = 11 22 | EDGES = 12 23 | SURFEDGES = 13 24 | MODELS = 14 25 | 26 | 27 | BSP_LUMPS = 15 28 | 29 | 30 | # Some Quake BSP utilities adapted from Matthew Earl's pyquake 31 | # https://github.com/matthewearl/pyquake/blob/2f1b0350dfab24b6557a37579cf15c78745e30e1/pyquake/bsp.py 32 | 33 | 34 | class BspVersion(enum.IntEnum): 35 | BSP = 29 36 | _2PSB = struct.unpack(" 0: 135 | assert vis.shape == (num_clusters, num_clusters) 136 | 137 | vis_lump = bytearray() 138 | 139 | patched_leaves = [] 140 | leaf_vis_offsets = {} 141 | 142 | if num_clusters > 0: 143 | for cluster_idx, cluster in enumerate(clusters): 144 | vis_offset = len(vis_lump) 145 | vis_bits = cluster_to_bits(vis[cluster_idx], portalleaves_real, clusters) 146 | vis_bits_compressed = compress_visbits(vis_bits) 147 | for vis_leaf_idx in cluster: 148 | leaf_vis_offsets[vis_leaf_idx + 1] = vis_offset 149 | 150 | vis_lump.extend(vis_bits_compressed) 151 | 152 | else: 153 | for leaf_idx, leaf in enumerate(leaves): 154 | logger.debug(f"{leaf_idx}, {leaf}") 155 | if leaf_idx == 0: 156 | continue 157 | 158 | if leaf_idx - 1 < vis.shape[0]: 159 | # Leaf #0 is ignored by visibility calculations, so we map a BSP leaf index to a visibility 160 | # leaf index by subtracting one. 161 | vis_full = vis[leaf_idx - 1] 162 | vis_bits = np.packbits(vis_full, bitorder="little") 163 | vis_bits_compressed = compress_visbits(bytearray(vis_bits)) 164 | leaf_vis_offsets[leaf_idx] = len(vis_lump) 165 | vis_lump.extend(vis_bits_compressed) 166 | else: 167 | leaf_vis_offsets[leaf_idx] = -1 168 | 169 | for leaf_idx, leaf in enumerate(leaves): 170 | logger.debug(f"{leaf_idx}, {leaf}") 171 | 172 | old_vis_offset = leaf[1] 173 | logger.debug(f" {old_vis_offset=}") 174 | if leaf_idx == 0: 175 | patched_leaves.append(leaf) 176 | continue 177 | vis_offset = leaf_vis_offsets.get(leaf_idx, -1) 178 | logger.debug(f" Set new {vis_offset=}") 179 | patched_leaves.append((leaf[0], vis_offset, *leaf[2:])) 180 | 181 | leaf_lump = write_leaves(patched_leaves, version) 182 | assert len(lumps_data[Q1Lump.LEAFS]) == len(leaf_lump) 183 | 184 | to_replace = {Q1Lump.LEAFS: leaf_lump, Q1Lump.VISIBILITY: vis_lump} 185 | 186 | with open(old_path, "rb") as inf: 187 | with open(new_path, "wb") as outf: 188 | outf.write(inf.read(4)) 189 | ofs_header = outf.tell() 190 | for i in range(BSP_LUMPS): 191 | outf.write(struct.pack("= -ON_EPSILON and d <= ON_EPSILON: 25 | sides.append(Side.PLANE) 26 | if d < -ON_EPSILON: 27 | sides.append(Side.BACK) 28 | num_back += 1 29 | if d > ON_EPSILON: 30 | sides.append(Side.FRONT) 31 | num_front += 1 32 | dists.append(d) 33 | 34 | sides.append(sides[0]) 35 | dists.append(dists[0]) 36 | 37 | if num_front == 0: 38 | return None 39 | if num_back == 0: 40 | return points 41 | 42 | for i, p1 in enumerate(points): 43 | if sides[i] == Side.PLANE: 44 | out.append(p1) 45 | continue 46 | 47 | if sides[i] == Side.FRONT: 48 | out.append(p1) 49 | 50 | if sides[i + 1] == Side.PLANE or sides[i + 1] == sides[i]: 51 | continue 52 | 53 | p2 = points[(i + 1) % len(points)] 54 | 55 | dot = dists[i] / (dists[i] - dists[i + 1]) 56 | mid = p1 + dot * (p2 - p1) 57 | 58 | # Avoid rounding error for axis-aligned planes 59 | for j in range(3): 60 | if plane.normal[j] == 1: 61 | mid[j] = plane.dist 62 | elif plane.normal[j] == -1: 63 | mid[j] = -plane.dist 64 | 65 | out.append(mid) 66 | 67 | if len(out) < 3: 68 | return None 69 | 70 | return out 71 | 72 | 73 | def test_if_points_in_front(points: list[Point], plane: Plane) -> bool: 74 | num_front = 0 75 | 76 | for k, pk in enumerate(points): 77 | d = plane.distance_to(pk) 78 | if d >= -ON_EPSILON and d <= ON_EPSILON: 79 | continue 80 | if d < -ON_EPSILON: 81 | return False 82 | if d > -ON_EPSILON: 83 | num_front += 1 84 | 85 | if num_front == 0: 86 | return False # All points were on plane 87 | 88 | return True 89 | 90 | 91 | def clip_to_separators( 92 | first_poly: Winding, 93 | first_plane: Plane, 94 | second_poly: Winding, 95 | clipped_poly: Winding, 96 | otherside=False, 97 | out_planes=None, 98 | ) -> Winding: 99 | """ 100 | Written to match the approach used in the original ClipToSeparators function. 101 | See https://github.com/fabiensanglard/Quake--QBSP-and-VIS/blob/e686204812f6464864e2959f9f57c1278409b70b/vis/flow.c#L40 102 | and https://github.com/ericwa/ericw-tools/blob/a6c7a18cb85cef64948d46780d4fc1bb3d1f575b/vis/flow.cc#L9 103 | for reference. 104 | """ 105 | clipped = clipped_poly 106 | 107 | for i in range(len(first_poly)): 108 | j = (i + 1) % len(first_poly) 109 | A = first_poly[i] 110 | B = first_poly[j] 111 | AB = B - A 112 | 113 | # Try different points C on the second portal 114 | for k, C in enumerate(second_poly): 115 | # Test on which side of the first portal point C is 116 | d = first_plane.distance_to(C) 117 | if d < -ON_EPSILON: 118 | # A separating plane must have the second portal on 119 | # its front side by definition. Here C is behind the 120 | # first portal, so this will not be the case after 121 | # normal = cross(AB, AC) 122 | # below and we'll have to flip the plane later. 123 | flip_towards_first = True 124 | elif d > ON_EPSILON: 125 | flip_towards_first = False 126 | else: 127 | continue # Point C is on the first polygon's plane 128 | 129 | AC = C - A 130 | normal = np.cross(AB, AC) 131 | mag = np.linalg.norm(normal) 132 | 133 | if mag < ON_EPSILON: 134 | continue # Portals might share vertices so there's no plane 135 | normal /= mag 136 | 137 | plane = Plane(normal, normal.dot(C)) 138 | 139 | if flip_towards_first: 140 | plane = -plane 141 | 142 | # Check if the plane is actually a separator 143 | if not test_if_points_in_front(second_poly, plane): 144 | continue 145 | 146 | # The 'otherside' flag is set if source and pass portals are swapped. 147 | # In that case, second_poly == source_poly, so the plane normal 148 | # points to the source and not the pass portal! 149 | # We'll flip the plane so that correct side gets clipped below. 150 | if otherside: 151 | plane = -plane 152 | 153 | if out_planes is not None: 154 | out_planes.append((C, plane)) # Only for debugging 155 | 156 | clipped = clip_winding(clipped, plane) 157 | 158 | if not clipped: 159 | return None 160 | 161 | return clipped 162 | -------------------------------------------------------------------------------- /data/example.bsp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pekkavaa/vis.py/0f786ffebe1a9b3e18372c681dcf85b56d4344b8/data/example.bsp -------------------------------------------------------------------------------- /data/example.map: -------------------------------------------------------------------------------- 1 | // Game: Quake 2 | // Format: Standard 3 | // entity 0 4 | { 5 | "wad" "gfx/base.wad;gfx/wizard.wad;gfx/metal.wad;gfx/medieval.wad;gfx/start.wad" 6 | "classname" "worldspawn" 7 | "property 1" "" 8 | "property 2" "" 9 | "light" "32" 10 | // brush 0 11 | { 12 | ( 96 -112 72 ) ( 96 -224 72 ) ( 96 -224 -8 ) city2_7 48 -8 0 1 1 13 | ( 96 -224 72 ) ( 144 -224 72 ) ( 144 -224 -8 ) city2_7 0 -8 0 1 1 14 | ( 144 -224 -8 ) ( 144 -112 -8 ) ( 96 -112 -8 ) city2_7 0 -48 0 1 1 15 | ( 96 -112 72 ) ( 144 -112 72 ) ( 144 -224 72 ) city2_7 0 -48 0 1 1 16 | ( 144 -112 -8 ) ( 144 -112 72 ) ( 96 -112 72 ) city2_7 0 -8 0 1 1 17 | ( 144 -224 72 ) ( 144 -112 72 ) ( 144 -112 -8 ) adoor01_2 112 -8 0 1 1 18 | } 19 | // brush 1 20 | { 21 | ( 144 -176 72 ) ( 144 -224 72 ) ( 144 -224 -8 ) city2_7 48 -8 0 1 1 22 | ( 144 -224 72 ) ( 192 -224 72 ) ( 192 -224 -8 ) city2_7 0 -8 0 1 1 23 | ( 192 -224 -8 ) ( 192 -176 -8 ) ( 144 -176 -8 ) city2_7 0 -48 0 1 1 24 | ( 144 -176 72 ) ( 192 -176 72 ) ( 192 -224 72 ) city2_7 0 -48 0 1 1 25 | ( 192 -176 -8 ) ( 192 -176 72 ) ( 144 -176 72 ) city2_7 0 -8 0 1 1 26 | ( 192 -224 72 ) ( 192 -176 72 ) ( 192 -176 -8 ) city2_7 48 -8 0 1 1 27 | } 28 | // brush 2 29 | { 30 | ( 96 -64 72 ) ( 96 -112 72 ) ( 96 -112 -8 ) city2_7 48 -8 0 1 1 31 | ( 96 -112 72 ) ( 384 -112 72 ) ( 384 -112 -8 ) city2_7 0 -8 0 1 1 32 | ( 384 -112 -8 ) ( 384 -64 -8 ) ( 96 -64 -8 ) city2_7 0 -48 0 1 1 33 | ( 96 -64 72 ) ( 384 -64 72 ) ( 384 -112 72 ) city2_7 0 -48 0 1 1 34 | ( 384 -64 -8 ) ( 384 -64 72 ) ( 96 -64 72 ) city2_7 0 -8 0 1 1 35 | ( 384 -112 72 ) ( 384 -64 72 ) ( 384 -64 -8 ) city2_7 48 -8 0 1 1 36 | } 37 | // brush 3 38 | { 39 | ( 336 -176 72 ) ( 336 -304 72 ) ( 336 -304 -8 ) city2_7 48 -8 0 1 1 40 | ( 336 -304 72 ) ( 384 -304 72 ) ( 384 -304 -8 ) city2_7 0 -8 0 1 1 41 | ( 384 -304 -8 ) ( 384 -176 -8 ) ( 336 -176 -8 ) city2_7 0 -48 0 1 1 42 | ( 336 -176 72 ) ( 384 -176 72 ) ( 384 -304 72 ) city2_7 0 -48 0 1 1 43 | ( 384 -176 -8 ) ( 384 -176 72 ) ( 336 -176 72 ) city2_7 0 -8 0 1 1 44 | ( 384 -304 72 ) ( 384 -176 72 ) ( 384 -176 -8 ) city2_7 48 -8 0 1 1 45 | } 46 | // brush 4 47 | { 48 | ( 192 -176 72 ) ( 192 -304 72 ) ( 192 -304 -8 ) city2_7 48 -8 0 1 1 49 | ( 192 -304 72 ) ( 240 -304 72 ) ( 240 -304 -8 ) city2_7 0 -8 0 1 1 50 | ( 240 -304 -8 ) ( 240 -176 -8 ) ( 192 -176 -8 ) city2_7 0 -48 0 1 1 51 | ( 192 -176 72 ) ( 240 -176 72 ) ( 240 -304 72 ) city2_7 0 -48 0 1 1 52 | ( 240 -176 -8 ) ( 240 -176 72 ) ( 192 -176 72 ) city2_7 0 -8 0 1 1 53 | ( 240 -304 72 ) ( 240 -176 72 ) ( 240 -176 -8 ) city2_7 48 -8 0 1 1 54 | } 55 | // brush 5 56 | { 57 | ( 192 -368 72 ) ( 192 -416 72 ) ( 192 -416 -8 ) city2_7 48 -8 0 1 1 58 | ( 192 -416 72 ) ( 384 -416 72 ) ( 384 -416 -8 ) city2_7 0 -8 0 1 1 59 | ( 384 -416 -8 ) ( 384 -368 -8 ) ( 192 -368 -8 ) city2_7 0 -48 0 1 1 60 | ( 192 -368 72 ) ( 384 -368 72 ) ( 384 -416 72 ) city2_7 0 -48 0 1 1 61 | ( 384 -368 -8 ) ( 384 -368 72 ) ( 192 -368 72 ) city2_7 0 -8 0 1 1 62 | ( 384 -416 72 ) ( 384 -368 72 ) ( 384 -368 -8 ) city2_7 48 -8 0 1 1 63 | } 64 | // brush 6 65 | { 66 | ( 384 -64 72 ) ( 384 -112 72 ) ( 384 -112 -8 ) city2_7 48 -8 0 1 1 67 | ( 384 -112 72 ) ( 640 -112 72 ) ( 640 -112 -8 ) city2_7 0 -8 0 1 1 68 | ( 640 -112 -8 ) ( 640 -64 -8 ) ( 384 -64 -8 ) city2_7 0 -48 0 1 1 69 | ( 384 -64 72 ) ( 640 -64 72 ) ( 640 -112 72 ) city2_7 0 -48 0 1 1 70 | ( 640 -64 -8 ) ( 640 -64 72 ) ( 384 -64 72 ) city2_7 0 -8 0 1 1 71 | ( 640 -112 72 ) ( 640 -64 72 ) ( 640 -64 -8 ) city2_7 48 -8 0 1 1 72 | } 73 | // brush 7 74 | { 75 | ( 592 -288 72 ) ( 592 -416 72 ) ( 592 -416 -8 ) city2_7 48 -8 0 1 1 76 | ( 592 -416 72 ) ( 640 -416 72 ) ( 640 -416 -8 ) city2_7 0 -8 0 1 1 77 | ( 640 -416 -8 ) ( 640 -288 -8 ) ( 592 -288 -8 ) city2_7 0 -48 0 1 1 78 | ( 592 -288 72 ) ( 640 -288 72 ) ( 640 -416 72 ) city2_7 0 -48 0 1 1 79 | ( 640 -288 -8 ) ( 640 -288 72 ) ( 592 -288 72 ) wizwood1_4 0 0 0 1 1 80 | ( 640 -416 72 ) ( 640 -288 72 ) ( 640 -288 -8 ) wizwood1_4 48 0 0 1 1 81 | } 82 | // brush 8 83 | { 84 | ( 384 -368 72 ) ( 384 -416 72 ) ( 384 -416 -8 ) city2_7 48 -8 0 1 1 85 | ( 384 -416 72 ) ( 592 -416 72 ) ( 592 -416 -8 ) city2_7 0 -8 0 1 1 86 | ( 592 -416 -8 ) ( 592 -368 -8 ) ( 384 -368 -8 ) city2_7 0 -48 0 1 1 87 | ( 384 -368 72 ) ( 592 -368 72 ) ( 592 -416 72 ) city2_7 0 -48 0 1 1 88 | ( 592 -368 -8 ) ( 592 -368 72 ) ( 384 -368 72 ) city2_7 0 -8 0 1 1 89 | ( 592 -416 72 ) ( 592 -368 72 ) ( 592 -368 -8 ) city2_7 48 -8 0 1 1 90 | } 91 | // brush 9 92 | { 93 | ( 640 -320 72 ) ( 640 -368 72 ) ( 640 -368 -8 ) city2_7 48 -8 0 1 1 94 | ( 640 -368 72 ) ( 832 -368 72 ) ( 832 -368 -8 ) city2_7 0 -8 0 1 1 95 | ( 832 -368 -8 ) ( 832 -320 -8 ) ( 640 -320 -8 ) city2_7 0 -48 0 1 1 96 | ( 640 -320 72 ) ( 832 -320 72 ) ( 832 -368 72 ) city2_7 0 -48 0 1 1 97 | ( 832 -320 -8 ) ( 832 -320 72 ) ( 640 -320 72 ) wizwood1_4 0 0 0 1 1 98 | ( 832 -368 72 ) ( 832 -320 72 ) ( 832 -320 -8 ) city2_7 48 -8 0 1 1 99 | } 100 | // brush 10 101 | { 102 | ( 832 -64 72 ) ( 832 -224 72 ) ( 832 -224 -8 ) wizwood1_4 0 0 0 1 1 103 | ( 832 -224 72 ) ( 880 -224 72 ) ( 880 -224 -8 ) wizwood1_4 16 0 0 1 1 104 | ( 880 -224 -8 ) ( 880 -64 -8 ) ( 832 -64 -8 ) city2_7 16 0 0 1 1 105 | ( 832 -64 72 ) ( 880 -64 72 ) ( 880 -224 72 ) city2_7 16 0 0 1 1 106 | ( 880 -64 -8 ) ( 880 -64 72 ) ( 832 -64 72 ) city2_7 16 -8 0 1 1 107 | ( 880 -224 72 ) ( 880 -64 72 ) ( 880 -64 -8 ) wizwood1_4 0 0 0 1 1 108 | } 109 | // brush 11 110 | { 111 | ( 640 -64 72 ) ( 640 -112 72 ) ( 640 -112 -8 ) city2_7 48 -8 0 1 1 112 | ( 640 -112 72 ) ( 832 -112 72 ) ( 832 -112 -8 ) wizwood1_4 0 0 0 1 1 113 | ( 832 -112 -8 ) ( 832 -64 -8 ) ( 640 -64 -8 ) city2_7 0 -48 0 1 1 114 | ( 640 -64 72 ) ( 832 -64 72 ) ( 832 -112 72 ) city2_7 0 -48 0 1 1 115 | ( 832 -64 -8 ) ( 832 -64 72 ) ( 640 -64 72 ) city2_7 0 -8 0 1 1 116 | ( 832 -112 72 ) ( 832 -64 72 ) ( 832 -64 -8 ) city2_7 48 -8 0 1 1 117 | } 118 | // brush 12 119 | { 120 | ( 592 -112 72 ) ( 592 -224 72 ) ( 592 -224 -8 ) city2_7 48 -8 0 1 1 121 | ( 592 -224 72 ) ( 640 -224 72 ) ( 640 -224 -8 ) wizwood1_4 0 0 0 1 1 122 | ( 640 -224 -8 ) ( 640 -112 -8 ) ( 592 -112 -8 ) city2_7 0 -48 0 1 1 123 | ( 592 -112 72 ) ( 640 -112 72 ) ( 640 -224 72 ) city2_7 0 -48 0 1 1 124 | ( 640 -112 -8 ) ( 640 -112 72 ) ( 592 -112 72 ) city2_7 0 -8 0 1 1 125 | ( 640 -224 72 ) ( 640 -112 72 ) ( 640 -112 -8 ) wizwood1_4 48 0 0 1 1 126 | } 127 | // brush 13 128 | { 129 | ( 720 -160 72 ) ( 720 -272 72 ) ( 720 -272 -8 ) wizwood1_4 48 0 0 1 1 130 | ( 720 -272 72 ) ( 768 -272 72 ) ( 768 -272 -8 ) wizwood1_4 0 0 0 1 1 131 | ( 768 -272 -8 ) ( 768 -160 -8 ) ( 720 -160 -8 ) city2_7 0 -48 0 1 1 132 | ( 720 -160 72 ) ( 768 -160 72 ) ( 768 -272 72 ) city2_7 0 -48 0 1 1 133 | ( 768 -160 -8 ) ( 768 -160 72 ) ( 720 -160 72 ) wizwood1_4 0 0 0 1 1 134 | ( 768 -272 72 ) ( 768 -160 72 ) ( 768 -160 -8 ) wizwood1_4 48 0 0 1 1 135 | } 136 | // brush 14 137 | { 138 | ( 832 -272 72 ) ( 832 -368 72 ) ( 832 -368 -8 ) wizwood1_4 0 0 0 1 1 139 | ( 832 -368 72 ) ( 880 -368 72 ) ( 880 -368 -8 ) city2_7 16 -8 0 1 1 140 | ( 880 -368 -8 ) ( 880 -272 -8 ) ( 832 -272 -8 ) city2_7 16 0 0 1 1 141 | ( 832 -272 72 ) ( 880 -272 72 ) ( 880 -368 72 ) city2_7 16 0 0 1 1 142 | ( 880 -272 -8 ) ( 880 -272 72 ) ( 832 -272 72 ) wizwood1_4 16 0 0 1 1 143 | ( 880 -368 72 ) ( 880 -272 72 ) ( 880 -272 -8 ) city2_7 0 -8 0 1 1 144 | } 145 | // brush 15 146 | { 147 | ( 880 -272 72 ) ( 880 -320 72 ) ( 880 -320 -8 ) wizwood1_4 0 0 0 1 1 148 | ( 880 -320 72 ) ( 976 -320 72 ) ( 976 -320 -8 ) city2_7 -32 -8 0 1 1 149 | ( 976 -320 -8 ) ( 976 -272 -8 ) ( 880 -272 -8 ) city2_7 -32 0 0 1 1 150 | ( 880 -272 72 ) ( 976 -272 72 ) ( 976 -320 72 ) city2_7 -32 0 0 1 1 151 | ( 976 -272 -8 ) ( 976 -272 72 ) ( 880 -272 72 ) wizwood1_4 -32 0 0 1 1 152 | ( 976 -320 72 ) ( 976 -272 72 ) ( 976 -272 -8 ) city2_7 0 -8 0 1 1 153 | } 154 | // brush 16 155 | { 156 | ( 880 -128 72 ) ( 880 -176 72 ) ( 880 -176 -8 ) wizwood1_4 48 0 0 1 1 157 | ( 880 -176 72 ) ( 976 -176 72 ) ( 976 -176 -8 ) wizwood1_4 32 0 0 1 1 158 | ( 976 -176 -8 ) ( 976 -128 -8 ) ( 880 -128 -8 ) city2_7 32 -48 0 1 1 159 | ( 880 -128 72 ) ( 976 -128 72 ) ( 976 -176 72 ) city2_7 32 -48 0 1 1 160 | ( 976 -128 -8 ) ( 976 -128 72 ) ( 880 -128 72 ) city2_7 32 -8 0 1 1 161 | ( 976 -176 72 ) ( 976 -128 72 ) ( 976 -128 -8 ) city2_7 48 -8 0 1 1 162 | } 163 | // brush 17 164 | { 165 | ( 976 -128 72 ) ( 976 -320 72 ) ( 976 -320 -8 ) wizwood1_4 48 0 0 1 1 166 | ( 976 -320 72 ) ( 1024 -320 72 ) ( 1024 -320 -8 ) city2_7 0 -8 0 1 1 167 | ( 1024 -320 -8 ) ( 1024 -128 -8 ) ( 976 -128 -8 ) city2_7 0 -48 0 1 1 168 | ( 976 -128 72 ) ( 1024 -128 72 ) ( 1024 -320 72 ) city2_7 0 -48 0 1 1 169 | ( 1024 -128 -8 ) ( 1024 -128 72 ) ( 976 -128 72 ) city2_7 0 -8 0 1 1 170 | ( 1024 -320 72 ) ( 1024 -128 72 ) ( 1024 -128 -8 ) city2_7 48 -8 0 1 1 171 | } 172 | // brush 18 173 | { 174 | ( 192 -304 72 ) ( 192 -368 72 ) ( 192 -368 -8 ) city2_7 48 -8 0 1 1 175 | ( 192 -368 72 ) ( 240 -368 72 ) ( 240 -368 -8 ) city2_7 0 -8 0 1 1 176 | ( 240 -368 -8 ) ( 240 -304 -8 ) ( 192 -304 -8 ) city2_7 0 -48 0 1 1 177 | ( 192 -304 72 ) ( 240 -304 72 ) ( 240 -368 72 ) city2_7 0 -48 0 1 1 178 | ( 240 -304 -8 ) ( 240 -304 72 ) ( 192 -304 72 ) city2_7 0 -8 0 1 1 179 | ( 240 -368 72 ) ( 240 -304 72 ) ( 240 -304 -8 ) city2_7 48 -8 0 1 1 180 | } 181 | } 182 | // entity 1 183 | { 184 | "classname" "info_player_start" 185 | "origin" "208 -160 16" 186 | } 187 | // entity 2 188 | { 189 | "classname" "light" 190 | "origin" "488 -224 64" 191 | "light" "600" 192 | } 193 | // entity 3 194 | { 195 | "classname" "light" 196 | "origin" "920 -200 48" 197 | "light" "200" 198 | } 199 | // entity 4 200 | { 201 | "classname" "func_group" 202 | "_tb_type" "_tb_layer" 203 | "_tb_name" "Ceiling" 204 | "_tb_id" "1" 205 | "_tb_layer_sort_index" "0" 206 | "_tb_layer_hidden" "1" 207 | // brush 0 208 | { 209 | ( 240 -112 88 ) ( 240 -368 88 ) ( 240 -368 72 ) city5_3 48 -16 0 1 1 210 | ( 240 -368 88 ) ( 592 -368 88 ) ( 592 -368 72 ) city5_3 0 -16 0 1 1 211 | ( 592 -368 72 ) ( 592 -112 72 ) ( 240 -112 72 ) city5_3 0 -48 0 1 1 212 | ( 240 -112 88 ) ( 592 -112 88 ) ( 592 -368 88 ) city5_3 0 -48 0 1 1 213 | ( 592 -112 72 ) ( 592 -112 88 ) ( 240 -112 88 ) city5_3 0 -16 0 1 1 214 | ( 592 -368 88 ) ( 592 -112 88 ) ( 592 -112 72 ) city5_3 48 -16 0 1 1 215 | } 216 | // brush 1 217 | { 218 | ( 144 -112 88 ) ( 144 -176 88 ) ( 144 -176 72 ) city5_3 48 -16 0 1 1 219 | ( 144 -176 88 ) ( 240 -176 88 ) ( 240 -176 72 ) city5_3 0 -16 0 1 1 220 | ( 240 -176 72 ) ( 240 -112 72 ) ( 144 -112 72 ) city5_3 0 -48 0 1 1 221 | ( 144 -112 88 ) ( 240 -112 88 ) ( 240 -176 88 ) city5_3 0 -48 0 1 1 222 | ( 240 -112 72 ) ( 240 -112 88 ) ( 144 -112 88 ) city5_3 0 -16 0 1 1 223 | ( 240 -176 88 ) ( 240 -112 88 ) ( 240 -112 72 ) city5_3 48 -16 0 1 1 224 | } 225 | // brush 2 226 | { 227 | ( 240 -112 88 ) ( 240 -368 88 ) ( 240 -368 72 ) city5_3 48 -16 0 1 1 228 | ( 240 -368 88 ) ( 336 -368 88 ) ( 336 -368 72 ) city5_3 0 -16 0 1 1 229 | ( 336 -368 72 ) ( 336 -112 72 ) ( 240 -112 72 ) city5_3 0 -48 0 1 1 230 | ( 240 -112 88 ) ( 336 -112 88 ) ( 336 -368 88 ) city5_3 0 -48 0 1 1 231 | ( 336 -112 72 ) ( 336 -112 88 ) ( 240 -112 88 ) city5_3 0 -16 0 1 1 232 | ( 336 -368 88 ) ( 336 -112 88 ) ( 336 -112 72 ) city5_3 48 -16 0 1 1 233 | } 234 | // brush 3 235 | { 236 | ( 592 -112 88 ) ( 592 -320 88 ) ( 592 -320 72 ) city5_3 48 -16 0 1 1 237 | ( 592 -320 88 ) ( 832 -320 88 ) ( 832 -320 72 ) city5_3 -16 -16 0 1 1 238 | ( 832 -320 72 ) ( 832 -112 72 ) ( 592 -112 72 ) city5_3 -16 -48 0 1 1 239 | ( 592 -112 88 ) ( 832 -112 88 ) ( 832 -320 88 ) city5_3 -16 -48 0 1 1 240 | ( 832 -112 72 ) ( 832 -112 88 ) ( 592 -112 88 ) city5_3 -16 -16 0 1 1 241 | ( 832 -320 88 ) ( 832 -112 88 ) ( 832 -112 72 ) city5_3 48 -16 0 1 1 242 | } 243 | // brush 4 244 | { 245 | ( 832 -176 88 ) ( 832 -272 88 ) ( 832 -272 72 ) city5_3 0 -16 0 1 1 246 | ( 832 -272 88 ) ( 976 -272 88 ) ( 976 -272 72 ) city5_3 0 -16 0 1 1 247 | ( 976 -272 72 ) ( 976 -176 72 ) ( 832 -176 72 ) city5_3 0 0 0 1 1 248 | ( 832 -176 88 ) ( 976 -176 88 ) ( 976 -272 88 ) city5_3 0 0 0 1 1 249 | ( 976 -176 72 ) ( 976 -176 88 ) ( 832 -176 88 ) city5_3 0 -16 0 1 1 250 | ( 976 -272 88 ) ( 976 -176 88 ) ( 976 -176 72 ) city5_3 0 -16 0 1 1 251 | } 252 | } 253 | // entity 5 254 | { 255 | "classname" "light" 256 | "origin" "680 -152 32" 257 | "_tb_layer" "1" 258 | } 259 | // entity 6 260 | { 261 | "classname" "light" 262 | "origin" "808 -296 32" 263 | "_tb_layer" "1" 264 | } 265 | // entity 7 266 | { 267 | "classname" "light" 268 | "origin" "296 -152 32" 269 | "light" "200" 270 | "_tb_layer" "1" 271 | } 272 | // entity 8 273 | { 274 | "classname" "func_group" 275 | "_tb_type" "_tb_layer" 276 | "_tb_name" "Floor" 277 | "_tb_id" "2" 278 | "_tb_layer_sort_index" "1" 279 | // brush 0 280 | { 281 | ( 96 -64 -8 ) ( 96 -224 -8 ) ( 96 -224 -24 ) mmetal1_1 48 -8 0 1 1 282 | ( 96 -224 -8 ) ( 240 -224 -8 ) ( 240 -224 -24 ) mmetal1_1 0 -8 0 1 1 283 | ( 240 -224 -24 ) ( 240 -64 -24 ) ( 96 -64 -24 ) mmetal1_1 0 -48 0 1 1 284 | ( 96 -64 -8 ) ( 240 -64 -8 ) ( 240 -224 -8 ) woodflr1_4 0 -48 0 1 1 285 | ( 240 -64 -24 ) ( 240 -64 -8 ) ( 96 -64 -8 ) mmetal1_1 0 -8 0 1 1 286 | ( 240 -224 -8 ) ( 240 -64 -8 ) ( 240 -64 -24 ) mmetal1_1 48 -8 0 1 1 287 | } 288 | // brush 1 289 | { 290 | ( 240 -112 -8 ) ( 240 -368 -8 ) ( 240 -368 -24 ) mmetal1_1 48 -8 0 1 1 291 | ( 240 -368 -8 ) ( 336 -368 -8 ) ( 336 -368 -24 ) mmetal1_1 0 -8 0 1 1 292 | ( 336 -368 -24 ) ( 336 -112 -24 ) ( 240 -112 -24 ) mmetal1_1 0 -48 0 1 1 293 | ( 240 -112 -8 ) ( 336 -112 -8 ) ( 336 -368 -8 ) woodflr1_4 0 -48 0 1 1 294 | ( 336 -112 -24 ) ( 336 -112 -8 ) ( 240 -112 -8 ) mmetal1_1 0 -8 0 1 1 295 | ( 336 -368 -8 ) ( 336 -112 -8 ) ( 336 -112 -24 ) mmetal1_1 48 -8 0 1 1 296 | } 297 | // brush 2 298 | { 299 | ( 336 -112 -8 ) ( 336 -368 -8 ) ( 336 -368 -24 ) mmetal1_1 48 -8 0 1 1 300 | ( 336 -368 -8 ) ( 592 -368 -8 ) ( 592 -368 -24 ) mmetal1_1 0 -8 0 1 1 301 | ( 592 -368 -24 ) ( 592 -112 -24 ) ( 336 -112 -24 ) mmetal1_1 0 -48 0 1 1 302 | ( 336 -112 -8 ) ( 592 -112 -8 ) ( 592 -368 -8 ) woodflr1_4 0 -48 0 1 1 303 | ( 592 -112 -24 ) ( 592 -112 -8 ) ( 336 -112 -8 ) mmetal1_1 0 -8 0 1 1 304 | ( 592 -368 -8 ) ( 592 -112 -8 ) ( 592 -112 -24 ) mmetal1_1 48 -8 0 1 1 305 | } 306 | // brush 3 307 | { 308 | ( 592 -112 -8 ) ( 592 -320 -8 ) ( 592 -320 -24 ) mmetal1_6 48 -8 0 1 1 309 | ( 592 -320 -8 ) ( 832 -320 -8 ) ( 832 -320 -24 ) mmetal1_6 48 -8 0 1 1 310 | ( 832 -320 -24 ) ( 832 -112 -24 ) ( 592 -112 -24 ) mmetal1_6 48 -48 0 1 1 311 | ( 592 -112 -8 ) ( 832 -112 -8 ) ( 832 -320 -8 ) woodflr1_4 48 -48 0 1 1 312 | ( 832 -112 -24 ) ( 832 -112 -8 ) ( 592 -112 -8 ) mmetal1_6 48 -8 0 1 1 313 | ( 832 -320 -8 ) ( 832 -112 -8 ) ( 832 -112 -24 ) mmetal1_6 48 -8 0 1 1 314 | } 315 | // brush 4 316 | { 317 | ( 832 -128 -8 ) ( 832 -320 -8 ) ( 832 -320 -24 ) mmetal1_6 48 -8 0 1 1 318 | ( 832 -320 -8 ) ( 1024 -320 -8 ) ( 1024 -320 -24 ) mmetal1_6 -16 -8 180 1 -1 319 | ( 1024 -320 -24 ) ( 1024 -128 -24 ) ( 832 -128 -24 ) mmetal1_6 -16 -48 180 1 -1 320 | ( 832 -128 -8 ) ( 1024 -128 -8 ) ( 1024 -320 -8 ) woodflr1_4 -16 -48 180 1 -1 321 | ( 1024 -128 -24 ) ( 1024 -128 -8 ) ( 832 -128 -8 ) mmetal1_6 -16 -8 180 1 -1 322 | ( 1024 -320 -8 ) ( 1024 -128 -8 ) ( 1024 -128 -24 ) mmetal1_6 48 -8 0 1 1 323 | } 324 | } 325 | -------------------------------------------------------------------------------- /data/example.prt: -------------------------------------------------------------------------------- 1 | PRT1 2 | 11 3 | 12 4 | 4 0 1 (880 -224 -8 ) (880 -272 -8 ) (880 -272 72 ) (880 -224 72 ) 5 | 4 1 2 (832 -224 -8 ) (832 -272 -8 ) (832 -272 72 ) (832 -224 72 ) 6 | 4 2 4 (768 -272 -8 ) (768 -320 -8 ) (768 -320 72 ) (768 -272 72 ) 7 | 4 2 3 (768 -112 72 ) (768 -112 -8 ) (768 -160 -8 ) (768 -160 72 ) 8 | 4 3 5 (720 -112 72 ) (720 -112 -8 ) (720 -160 -8 ) (720 -160 72 ) 9 | 4 4 5 (720 -272 -8 ) (720 -320 -8 ) (720 -320 72 ) (720 -272 72 ) 10 | 4 5 6 (640 -224 -8 ) (640 -288 -8 ) (640 -288 72 ) (640 -224 72 ) 11 | 4 6 7 (592 -224 -8 ) (592 -288 -8 ) (592 -288 72 ) (592 -224 72 ) 12 | 4 7 10 (384 -304 -8 ) (384 -368 -8 ) (384 -368 72 ) (384 -304 72 ) 13 | 4 7 8 (384 -112 -8 ) (384 -176 -8 ) (384 -176 72 ) (384 -112 72 ) 14 | 4 8 9 (240 -176 -8 ) (336 -176 -8 ) (336 -176 72 ) (240 -176 72 ) 15 | 4 9 10 (240 -304 -8 ) (336 -304 -8 ) (336 -304 72 ) (240 -304 72 ) 16 | -------------------------------------------------------------------------------- /docs/LICENSE: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright 2025 Pekka Väänänen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /docs/matplotlib.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pekkavaa/vis.py/0f786ffebe1a9b3e18372c681dcf85b56d4344b8/docs/matplotlib.png -------------------------------------------------------------------------------- /portaltypes.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import enum 3 | from dataclasses import dataclass 4 | 5 | Point = np.ndarray # a 3D point 6 | Winding = list[Point] # a convex polygon 7 | 8 | 9 | @dataclass 10 | class Plane: 11 | normal: np.ndarray 12 | dist: float 13 | 14 | def distance_to(self, p: Point): 15 | return self.normal.dot(p) - self.dist 16 | 17 | def __neg__(self): 18 | return Plane(-self.normal, -self.dist) 19 | 20 | 21 | class ProcessStatus(enum.IntEnum): 22 | NONE = 0 23 | WORKING = 1 24 | DONE = 2 25 | 26 | 27 | class Portal: 28 | winding: Winding # a convex polygon 29 | leaf: int # index of the leaf where this portal leads 30 | plane: Plane # plane normal points towards the leaf 31 | num_mightsee = 0 32 | num_cansee = 0 33 | mightsee: np.ndarray = None # what leaves are roughly visible 34 | vis: np.ndarray = None # what leaves are visible 35 | 36 | def __init__(self, winding, leaf, plane): 37 | self.winding = winding 38 | self.leaf = leaf 39 | self.plane = plane 40 | 41 | points = np.array(self.winding) 42 | origin = points.mean(axis=0, keepdims=True) 43 | self.sphere_origin = origin[0] 44 | self.sphere_radius = np.max(np.linalg.norm(points - origin, axis=1)) 45 | 46 | 47 | @dataclass 48 | class Leaf: 49 | portals: list[int] # A list of portal indices 50 | 51 | 52 | def get_winding_plane(winding: Winding) -> Plane: 53 | """ 54 | Plane normal points inside the leaf containing the winding. 55 | So the "front" face is counter-clockwise bound and the normal 56 | points away from camera when portal is looked through. 57 | """ 58 | normal = np.cross(winding[0] - winding[1], winding[2] - winding[1]) 59 | normal /= np.linalg.norm(normal) 60 | return Plane(normal, winding[0].dot(normal)) 61 | -------------------------------------------------------------------------------- /prt_format.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from portaltypes import Portal, Leaf, Plane, get_winding_plane 3 | 4 | 5 | def parse_prt_file(text): 6 | lines = text.strip().split("\n") 7 | version = 1 8 | versionstr = lines.pop(0) 9 | 10 | if versionstr == "PRT1": 11 | version = 1 12 | elif versionstr == "PRT2": 13 | version = 2 14 | else: 15 | raise ValueError("Invalid PRT format") 16 | 17 | num_leaves = int(lines.pop(0)) 18 | num_clusters = int(lines.pop(0)) if version == 2 else 0 19 | num_portals = int(lines.pop(0)) 20 | portalleafs_real = num_leaves 21 | 22 | portals = [] 23 | if num_clusters > 0: 24 | num_leaves = num_clusters 25 | leaves = [Leaf([]) for _ in range(num_leaves)] 26 | 27 | for i in range(num_portals): 28 | parts = lines.pop(0).replace("(", "").replace(")", "").split() 29 | num_points = int(parts.pop(0)) 30 | leaf0 = int(parts.pop(0)) 31 | leaf1 = int(parts.pop(0)) 32 | 33 | winding = [] 34 | for i in range(0, len(parts), 3): 35 | point = np.array([float(parts[i]), float(parts[i + 1]), float(parts[i + 2])]) 36 | winding.append(point) 37 | 38 | assert len(winding) == num_points 39 | 40 | # Get a plane that points to leaf0, the containing leaf of the forward portal. 41 | plane = get_winding_plane(winding) 42 | 43 | # The "forward portal" leads from leaf0 to leaf1 so its plane is flipped to point to the target leaf. 44 | forward_portal = Portal(winding, leaf1, Plane(-plane.normal, -plane.dist)) 45 | portals.append(forward_portal) 46 | leaves[leaf0].portals.append(len(portals) - 1) 47 | 48 | backward_portal = Portal(winding[::-1], leaf0, plane) 49 | portals.append(backward_portal) 50 | leaves[leaf1].portals.append(len(portals) - 1) 51 | 52 | clusters = [] 53 | for cluster_id in range(num_clusters): 54 | parts = lines.pop(0).split(" ") 55 | assert parts[-1] == "-1" 56 | leaf_ids = [int(s) for s in parts[:-1]] 57 | clusters.append(leaf_ids) 58 | 59 | return leaves, portals, clusters, portalleafs_real 60 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | matplotlib==3.9.* 2 | numpy==2.0.* 3 | -------------------------------------------------------------------------------- /vis.py: -------------------------------------------------------------------------------- 1 | import time 2 | import sys 3 | import numpy as np 4 | from argparse import ArgumentParser 5 | from pathlib import Path 6 | from typing import Union 7 | 8 | from portaltypes import Plane, Winding, Portal 9 | from prt_format import parse_prt_file 10 | from clipper import clip_winding 11 | 12 | 13 | parser = ArgumentParser() 14 | parser.add_argument("prt_path", help="Input PRT file") 15 | parser.add_argument("input_bsp", help="Input BSP file") 16 | parser.add_argument("output_bsp", help="Output BSP file") 17 | parser.add_argument("--level", type=int, default=4, help="Test accuracy level from 0 to 4") 18 | parser.add_argument("--fast", action="store_true", help="Only compute coarse visibility") 19 | parser.add_argument("--verbose", action="store_true", help="More logging") 20 | parser.add_argument("--noviz", action="store_true", help="Hide visualization") 21 | parser.add_argument("--pickle", help="Dump the visibility data path to this file") 22 | 23 | args = parser.parse_args() 24 | if args.input_bsp is None: 25 | bsp_path = Path(args.prt_path).with_suffix(".bsp") 26 | else: 27 | bsp_path = args.input_bsp 28 | 29 | 30 | verbose = args.verbose 31 | test_level = args.level 32 | show_viz = not args.noviz 33 | 34 | 35 | def dprint(*args, level=0, **kwargs): 36 | if verbose: 37 | print(*args, **kwargs) 38 | 39 | 40 | # Load the portal file 41 | 42 | text = open(args.prt_path, "r").read() 43 | leaves, portals, clusters, portalleafs_real = parse_prt_file(text) 44 | print(f"{len(leaves)} leaves, {len(portals)} portals, {len(clusters)} clusters") 45 | 46 | num_leaves = len(leaves) 47 | num_clusters = len(clusters) 48 | num_portals = len(portals) 49 | 50 | if num_clusters > 0: 51 | assert ( 52 | num_leaves == num_clusters 53 | ), "In PRT2 files, each leaf actually represents a cluster of leaves" 54 | 55 | # Allocate an array for the coarse results and hand out rows of it to portals. 56 | # The more accurate vis results are kept in an array allocated later. 57 | 58 | portal_mightsee = np.zeros((num_portals, num_leaves), bool) 59 | 60 | for pi, Pi in enumerate(portals): 61 | Pi.mightsee = portal_mightsee[pi, :] 62 | 63 | # Coarse or "base" vis 64 | 65 | 66 | def portal_might_see_other(portal: Portal, other: Portal): 67 | # Test 1 68 | # 'other' must have at least one point in front of 'portal' 69 | for q in other.winding: 70 | d = portal.plane.distance_to(q) 71 | if d > 0.001: 72 | break 73 | else: 74 | return False # no points in front 75 | 76 | # Test 2 77 | # 'portal' must have at least one point behind 'other' 78 | for p in portal.winding: 79 | d = other.plane.distance_to(p) 80 | if d < -0.001: 81 | break 82 | else: 83 | return False # no points in back of other 84 | 85 | # Test 3 86 | # Portals shouldn't face each other 87 | if portal.plane.normal.dot(other.plane.normal) < -0.99: 88 | return False 89 | 90 | return True 91 | 92 | 93 | def base_portal_flow(pi, Pi): 94 | print(f"Flooding portal {pi+1}/{len(portals)}") 95 | 96 | mightsee = np.zeros(num_leaves, dtype=bool) 97 | 98 | def simple_flood(leafnum, level): 99 | if mightsee[leafnum]: 100 | return 101 | 102 | mightsee[leafnum] = True 103 | 104 | leaf = leaves[leafnum] 105 | 106 | for pk in leaf.portals: 107 | if portal_might_see_other(portals[pi], portals[pk]): 108 | simple_flood(portals[pk].leaf, level + 1) 109 | 110 | simple_flood(portals[pi].leaf, 0) 111 | return mightsee 112 | 113 | 114 | for pi, Pi in enumerate(portals): 115 | Pi.mightsee[:] = base_portal_flow(pi, Pi) 116 | 117 | for Pi in portals: 118 | Pi.num_mightsee = np.sum(Pi.mightsee) 119 | avg_num_mightsee = np.mean([Pi.num_mightsee for Pi in portals]) 120 | 121 | print("Base vis flooding done") 122 | print(f"Average 'mightsee' leaves visible: {avg_num_mightsee}") 123 | 124 | 125 | def print_matrix(A, column_label="", row_label="", idx_start=0): 126 | print(column_label) 127 | for pi in range(A.shape[0]): 128 | row_str = "".join("x " if A[pi, li] else ". " for li in range(A.shape[1])) 129 | print(f"{(pi+idx_start):2d} {row_str}") 130 | column_numbers = "".join(f"{i:2d}" for i in range(idx_start, A.shape[1] + idx_start)) 131 | print(f" {column_numbers} {row_label}") 132 | 133 | 134 | if verbose: 135 | print("The portal_mightsee matrix:") 136 | print_matrix(portal_mightsee, "portal", "leaf") 137 | print() 138 | 139 | # Do recursive portal clipping 140 | 141 | from clipper import clip_to_separators 142 | 143 | start_time = time.time() 144 | 145 | portal_vis = np.zeros((num_portals, num_leaves), dtype=bool) 146 | 147 | # Assign each portal a row of the portal->leaf visibility matrix 148 | for pi, Pi in enumerate(portals): 149 | Pi.vis = portal_vis[pi, :] 150 | 151 | # We can accelerate the processing a bit if we know which portals already have 152 | # their final visibility 153 | portal_done = np.zeros(num_portals, dtype=bool) 154 | 155 | 156 | def portal_flow(ps: int, Ps: Portal): 157 | def leaf_flow( 158 | leafnum: int, 159 | mightsee: np.ndarray, 160 | src_poly: Winding, 161 | pass_plane: Plane, 162 | pass_poly: Union[Winding, None], 163 | ): 164 | Ps.vis[leafnum] = True 165 | 166 | # Test every portal leading away from this leaf 167 | for pt in leaves[leafnum].portals: 168 | Pt = portals[pt] # Candidate target portal 169 | 170 | # Can the previous portal possibly see the target leaf? 171 | if not mightsee[Pt.leaf]: 172 | continue 173 | 174 | # Use the final visibility array if the portal has been processed 175 | if portal_done[pt]: 176 | test = Pt.vis 177 | else: 178 | test = Pt.mightsee 179 | 180 | # Filter away any leaves that couldn't be seen by earlier portals 181 | might = np.bitwise_and(mightsee, test) 182 | 183 | # Skip if we could see only leaves that have already proven visible 184 | if not any(np.bitwise_and(might, np.bitwise_not(Ps.vis))): 185 | continue 186 | 187 | # Clip the target portal to source portal's plane 188 | if not (target_poly := clip_winding(Pt.winding, Ps.plane)): 189 | continue 190 | 191 | # Immediate neighbors don't need other checks 192 | if pass_poly is None: 193 | leaf_flow(Pt.leaf, might, src_poly, Pt.plane, target_poly) 194 | continue 195 | 196 | # Make sure the target and source portals are in front and behind 197 | # of the pass portal, respectively 198 | 199 | if not (target_poly := clip_winding(target_poly, pass_plane)): 200 | continue 201 | 202 | if not (src_clipped := clip_winding(src_poly, -pass_plane)): 203 | continue 204 | 205 | # Finally clip the target and source polygons with separating planes 206 | 207 | if test_level > 0: 208 | target_poly = clip_to_separators( 209 | src_clipped, Ps.plane, pass_poly, target_poly) 210 | if not target_poly: 211 | continue 212 | 213 | if test_level > 1: 214 | target_poly = clip_to_separators( 215 | pass_poly, pass_plane, src_clipped, target_poly, otherside=True 216 | ) 217 | if not target_poly: 218 | continue 219 | 220 | if test_level > 2: 221 | src_clipped = clip_to_separators( 222 | target_poly, Pt.plane, pass_poly, src_clipped) 223 | if not src_clipped: 224 | continue 225 | 226 | if test_level > 3: 227 | src_clipped = clip_to_separators( 228 | pass_poly, pass_plane, target_poly, src_clipped, otherside=True 229 | ) 230 | if not src_clipped: 231 | continue 232 | 233 | # If all the checks passed we enter the leaf behind portal 'Pt'. 234 | # The old target portal becomes the new pass portal. The 'might' 235 | # list is now filtered more. Both 'src_clipped' and 'target_poly' 236 | # polygons may have been clipped smaller. 237 | 238 | leaf_flow(Pt.leaf, might, src_clipped, Pt.plane, target_poly) 239 | 240 | leaf_flow(Ps.leaf, Ps.mightsee, Ps.winding, Ps.plane, None) 241 | portal_done[ps] = True 242 | 243 | 244 | if args.fast: 245 | # In fast vis we just copy over the coarse results 246 | print("Doing fast vis") 247 | for prt in portals: 248 | prt.vis[:] = prt.mightsee 249 | else: 250 | # In full vis the portals are sorted by complexity so that simpler ones are completed first 251 | print("Doing full vis") 252 | sorted_portals = sorted(enumerate(portals), key=lambda pair: pair[1].num_mightsee) 253 | 254 | count = 0 255 | for ps, Ps in (sorted_portals): 256 | print(f"[{count+1}/{len(sorted_portals)}] portal {ps} with {Ps.num_mightsee} possibly visible leaves") 257 | portal_flow(ps, Ps) 258 | count += 1 259 | 260 | print(f"Portal flow done in {time.time() - start_time:.3f} s") 261 | 262 | if verbose: 263 | dprint("The portal_vis matrix:") 264 | print_matrix(portal_vis, "portal", "leaf", idx_start=1) 265 | 266 | 267 | avg_see = 0 268 | for Pi in portals: 269 | avg_see += np.sum(Pi.vis) 270 | avg_see /= len(portals) 271 | print(f"Average leaves visible per portal: {avg_see:.1f}") 272 | 273 | 274 | print("Filling in leaf visibilities") 275 | 276 | final_vis = np.zeros((num_leaves, num_leaves), dtype=bool) 277 | for li, leaf in enumerate(leaves): 278 | for pi in leaf.portals: 279 | np.bitwise_or(final_vis[li], portal_vis[pi], out=final_vis[li]) 280 | final_vis[li, li] = True 281 | 282 | if verbose: 283 | dprint("The vis matrix (one-based indices):") 284 | print_matrix(final_vis, "leaf", "leaf", idx_start=1) 285 | 286 | # Save the results 287 | 288 | if args.pickle: 289 | import pickle 290 | 291 | print(f"Saving to {args.pickle}") 292 | with open(args.pickle, "wb") as f: 293 | pickle.dump( 294 | { 295 | "vis": final_vis, 296 | "clusters": clusters, 297 | "portalleafs_real": portalleafs_real, 298 | }, 299 | f, 300 | ) 301 | 302 | import bsp_format 303 | 304 | bsp_format.update_bsp_leaf_visibility( 305 | bsp_path, args.output_bsp, final_vis, clusters, portalleafs_real 306 | ) 307 | 308 | # 3D visualization 309 | 310 | if not show_viz: 311 | print("Done") 312 | sys.exit(0) 313 | 314 | import matplotlib.pyplot as plt 315 | 316 | fig = plt.figure(figsize=(10, 8)) 317 | ax = fig.add_subplot(111, projection="3d") 318 | 319 | 320 | def plot_line(a, b, **kwargs): 321 | ax.plot([a[0], b[0]], [a[1], b[1]], [a[2], b[2]], **kwargs) 322 | 323 | 324 | def plot_text(p, text, **kwargs): 325 | ax.text(p[0], p[1], p[2], text, **kwargs) 326 | 327 | 328 | def plot_winding(points, **kwargs): 329 | if len(points) < 3: 330 | print("Warning: Trying to plot a degenerate portal") 331 | return 332 | 333 | x = [p[0] for p in points] 334 | y = [p[1] for p in points] 335 | z = [p[2] for p in points] 336 | 337 | mid = np.array([np.sum(x), np.sum(y), np.sum(z)]) / len(x) 338 | 339 | x.append(x[0]) 340 | y.append(y[0]) 341 | z.append(z[0]) 342 | 343 | c = ["red", "pink", "olive", "green", "blue", "navy"] 344 | for i in range(len(x) - 1): 345 | if "color" in kwargs: 346 | ax.plot([x[i], x[i + 1]], [y[i], y[i + 1]], [z[i], z[i + 1]], **kwargs) 347 | else: 348 | ax.plot( 349 | [x[i], x[i + 1]], 350 | [y[i], y[i + 1]], 351 | [z[i], z[i + 1]], 352 | color=c[i % len(c)], 353 | **kwargs, 354 | ) 355 | return mid 356 | 357 | 358 | def plot_portal(portal, text=None, show_radius=False, **kwargs): 359 | def normalize(v): 360 | return v / np.linalg.norm(v) 361 | 362 | points = portal.winding 363 | normal = portal.plane.normal 364 | mid = plot_winding(points, **kwargs) 365 | midn = mid + 8.0 * normal 366 | 367 | if text: 368 | mid2 = mid + 9.0 * normal 369 | ax.text(mid2[0], mid2[1], mid2[2], text) 370 | 371 | if "color" in kwargs: 372 | plot_line(mid, midn, **kwargs) 373 | else: 374 | plot_line(mid, midn, color="grey", **kwargs) 375 | 376 | if show_radius: 377 | dir = normalize(points[0] - portal.sphere_origin) 378 | plot_line( 379 | portal.sphere_origin, 380 | portal.sphere_origin + portal.sphere_radius * dir, 381 | color="blue", 382 | ) 383 | 384 | 385 | if show_viz: 386 | # Draw all portals 387 | for idx, port in enumerate(portals): 388 | if idx % 2 == 0: # Draw only forward portals 389 | plot_portal(port, text=str(idx)) 390 | pass 391 | 392 | for idx, leaf in enumerate(leaves): 393 | origin = np.array([0.0, 0.0, 0.0]) 394 | for pi in leaf.portals: 395 | Pi = portals[pi] 396 | mid = np.mean(Pi.winding, axis=0) 397 | origin += mid 398 | origin /= len(leaf.portals) 399 | plot_text(origin, f"$L_{{{idx}}}$", color="darkgrey") 400 | 401 | ax.set_xlabel("X") 402 | ax.set_ylabel("Y") 403 | ax.set_zlabel("Z") 404 | ax.set_zlim(-100, 100) 405 | ax.set_aspect("equal") 406 | fig.tight_layout() 407 | plt.show() 408 | --------------------------------------------------------------------------------