Faster raycast, better mapgen and much better HUD
dkl9

dkl9 commited on 2025-174 14:21:36
Showing 10 changed files, with 230 additions and 119 deletions.

... ...
@@ -0,0 +1,4 @@
1
+*.swp
2
+*.log
3
+venv
4
+tags
... ...
@@ -4,7 +4,7 @@ run:
4 4
 	python main.py
5 5
 
6 6
 dbg:
7
-	python -m pdb -c 'break debug_mode' main.py
7
+	python -m pdb -c 'break debug_mode ;; cont' main.py
8 8
 
9 9
 prof:
10 10
 	python -m cProfile main.py >$(PROF)
... ...
@@ -1,26 +1,12 @@
1 1
 from libs import *
2 2
 
3
-# https://iquilezles.org/articles/intersectors/
4
-def intersect_tri(ro, rd, tv):
5
-    e1 = tv[1] - tv[0]
6
-    e2 = tv[2] - tv[0]
7
-    to = ro - tv[0]
8
-    n = np.cross(e1, e2)
9
-    q = np.cross(to, rd)
10
-    d = 1.0 / np.dot(rd, n)
11
-    u = d * np.dot(-q, e2)
12
-    v = d * np.dot(q, e1)
13
-    t = d * np.dot(-n, to)
14
-    if u > 0.0 and v > 0.0 and u + v < 1.0 and t > 1e-3:
15
-        return t
16
-
17 3
 class Camera:
18 4
     def __init__(self, **kwargs):
19 5
         self.pos = vec([0, 0, 0])
20 6
         self.theta = 0
21 7
         self.phi = 0
22 8
         self.fovy = kwargs.get("fovy", 90)
23
-        self.aspect = 1
9
+        self.viewport = (640, 480)
24 10
 
25 11
     def looking(self):
26 12
         return vec([
... ...
@@ -32,6 +18,9 @@ class Camera:
32 18
     def up(self):
33 19
         return vec([0, 1, 0])
34 20
 
21
+    def aspect(self):
22
+        return self.viewport[0] / self.viewport[1]
23
+
35 24
     def view_mat(self):
36 25
         f = normalize(self.looking())
37 26
         s = normalize(np.cross(f, self.up()))
... ...
@@ -47,7 +36,7 @@ class Camera:
47 36
         f = 1.0 / np.tan(np.radians(self.fovy) / 2)
48 37
         depth = near - far
49 38
         return vec([
50
-            [f / self.aspect, 0, 0, 0],
39
+            [f / self.aspect(), 0, 0, 0],
51 40
             [0, f, 0, 0],
52 41
             [0, 0, (near + far) / depth, (2 * near * far) / depth],
53 42
             [0, 0, -1, 0]
... ...
@@ -56,7 +45,7 @@ class Camera:
56 45
     def mvp(self, ent):
57 46
         if ent.hud:
58 47
             am = np.identity(4, dtype=FLOAT)
59
-            am[0, 0] = 1 / self.aspect
48
+            am[0, 0] = 1 / self.aspect()
60 49
             return ent.translate() @ am @ ent.orient
61 50
         else:
62 51
             return self.persp_mat() @ self.view_mat() @ ent.translate() @ ent.orient
... ...
@@ -72,16 +61,7 @@ class Camera:
72 61
         l = self.looking()
73 62
         d, c, n = None, None, None
74 63
         for ent in scene:
75
-            if ent.hud:
76
-                continue
77
-            elif ent.draw_mode == GL_TRIANGLE_STRIP:
78
-                tl = ((i, ent.verts[i:i + 3]) for i in range(len(ent.verts) - 2))
79
-            elif ent.draw_mode == GL_TRIANGLES:
80
-                tl = ((i, ent.verts[3 * i:3 * (i + 1)]) for i in range(len(ent.verts) // 3))
81
-            else:
82
-                raise NotImplementedError
83
-            for (i, t) in tl:
84
-                r = intersect_tri(self.pos, l, np.dot(t, ent.orient[:3, :3].T) + ent.pos)
64
+            r, i = ent.raycast(self.pos, l)
85 65
             if r and (d is None or r < d):
86 66
                 d, c, n = r, ent, i
87 67
         return c, n
... ...
@@ -1,5 +1,19 @@
1 1
 from libs import *
2 2
 
3
+# https://iquilezles.org/articles/intersectors/
4
+def intersect_tri(ro, rd, tv):
5
+    e1 = tv[1] - tv[0]
6
+    e2 = tv[2] - tv[0]
7
+    to = ro - tv[0]
8
+    n = np.cross(e1, e2)
9
+    q = np.cross(to, rd)
10
+    d = 1.0 / np.dot(rd, n)
11
+    u = d * np.dot(-q, e2)
12
+    v = d * np.dot(q, e1)
13
+    t = d * np.dot(-n, to)
14
+    if u > 0.0 and v > 0.0 and u + v < 1.0 and t > 1e-3:
15
+        return t
16
+
3 17
 class Entity:
4 18
     def __init__(self, verts, col, pos, **kwargs):
5 19
         self.verts = vec(verts)
... ...
@@ -53,3 +67,19 @@ class Entity:
53 67
 
54 68
     def click(self, button, face):
55 69
         self.onclick(self, button, face)
70
+
71
+    def raycast(self, ro, rd):
72
+        if self.hud or self.draw_mode == GL_LINES:
73
+            return
74
+        elif self.draw_mode == GL_TRIANGLE_STRIP:
75
+            tl = ((i, self.verts[i:i + 3]) for i in range(len(self.verts) - 2))
76
+        elif self.draw_mode == GL_TRIANGLES:
77
+            tl = ((i, self.verts[3 * i:3 * (i + 1)]) for i in range(len(self.verts) // 3))
78
+        else:
79
+            raise NotImplementedError
80
+        d, n = None, None
81
+        for (i, t) in tl:
82
+            r = intersect_tri(ro, rd, np.dot(t, self.orient[:3, :3].T) + self.pos)
83
+            if r and (d is None or r < d):
84
+                d, n = r, i
85
+        return d, n
... ...
@@ -23,3 +23,8 @@ def roll_vec3(l, k):
23 23
             l[:] = [l[2], l[0], l[1]]
24 24
         case 2:
25 25
             l[:] = [l[1], l[2], l[0]]
26
+
27
+def axis2offset(a, x=1):
28
+    o = [0, 0, x * np.sign(a)]
29
+    roll_vec3(o, abs(a))
30
+    return o
... ...
@@ -1,6 +1,8 @@
1 1
 from OpenGL.GL import shaders
2 2
 from libs import *
3 3
 from entity import Entity
4
+from mapgen import MapGen
5
+from mesh import SquareMesh
4 6
 from player import Player
5 7
 from region import WorldRegion
6 8
 
... ...
@@ -24,28 +26,27 @@ void main() {
24 26
 }
25 27
 """
26 28
 
27
-FPS = 240
29
+FPS = 60
28 30
 
29
-def debug_mode():
31
+def debug_mode(scene, cam):
30 32
     pass
31 33
 
32
-def click_mesh(self, button, ind):
33
-    a, p = self.faces[ind]
34
+def click_mesh(self, button, coords):
34 35
     match button:
35 36
         case 1:
36
-            self.del_voxel(ind)
37
+            self.set_voxel(coords[1])
37 38
         case 2:
38
-            print(f"face {a} of {p}")
39
+            print(f"coords {coords}")
39 40
         case 3:
40
-            self.add_voxel(p + np.sign(a) * np.roll([0, 0, 1], abs(a)))
41
+            offset = axis2offset(coords[0])
42
+            self.set_voxel(coords[1] + offset, 2)
41 43
     if button % 2 == 1:
42
-        self.remesh()
43 44
         self.update_attr(0)
44 45
         self.update_attr(1)
45 46
 
46
-def build_scene():
47
+def build_scene(mg):
47 48
     box = WorldRegion()
48
-    print(f"map generated with {box.gen_perlin(np.random.rand(2, 2, 2, 3))} voxels")
49
+    print(f"map generated with {mg.world_natural(box)} voxels")
49 50
     box.remesh()
50 51
     box.onclick = click_mesh
51 52
     return [box]
... ...
@@ -58,13 +59,14 @@ def main():
58 59
         shaders.compileShader(VS, GL_VERTEX_SHADER),
59 60
         shaders.compileShader(FS, GL_FRAGMENT_SHADER)
60 61
     )
62
+    loc_mvp = glGetUniformLocation(sp, "MVP")
61 63
     glUseProgram(sp)
62 64
     glEnable(GL_DEPTH_TEST)
63
-    loc_mvp = glGetUniformLocation(sp, "MVP")
64 65
     glClearColor(0.1, 0.1, 0.1, 1.0)
65
-    scene = build_scene()
66
+    mg = MapGen()
67
+    scene = build_scene(mg)
66 68
     clock = pygame.time.Clock()
67
-    cam = Player()
69
+    cam = Player(speed=10/FPS)
68 70
     cam.pos = [8, 16, 8]
69 71
     cam.set_focus(True)
70 72
     while True:
... ...
@@ -73,16 +75,15 @@ def main():
73 75
                 pygame.quit()
74 76
                 return
75 77
             elif ev.type == pygame.KEYUP and ev.unicode == "d":
76
-                debug_mode()
78
+                debug_mode(scene, cam)
77 79
             cam.handle_event(ev, scene)
78 80
         cam.update_pos()
79 81
         glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
80
-        perf = clock.get_fps() / FPS
81
-        cam.hud["fps"].col = GREEN if 0.9 < perf < 1.1 else YELLOW if perf > 0.5 else RED
82
-        cam.hud["fps"].update_attr(1)
83
-        for ent in itertools.chain(scene, cam.hud.values()):
82
+        for ent in scene:
84 83
             cam.draw(ent, loc_mvp)
84
+        perf = clock.get_fps() / FPS
85
+        cam.draw_hud(loc_mvp, scene[0], perf)
85 86
         pygame.display.flip()
86
-        clock.tick(240)
87
+        clock.tick(FPS)
87 88
 
88 89
 main()
... ...
@@ -0,0 +1,57 @@
1
+from libs import *
2
+
3
+def smerp(ab, w):
4
+    assert all([abs(x) < 2 for x in ab])
5
+    for i in range(w.shape[0]):
6
+        ab = [a + w[i] ** 2 * (3 - 2 * w[i]) * (b - a) for (a, b) in zip(ab[::2], ab[1::2])]
7
+    return ab[0]
8
+
9
+class MapGen:
10
+    def __init__(self):
11
+        self.grads = [{} for _ in range(4)]
12
+
13
+    def perlin(self, name, pos, period):
14
+        dim = pos.shape[0]
15
+        gt = self.grads[dim].setdefault(name, {})
16
+        corner, fract = np.divmod(pos, period)
17
+        fract /= period
18
+        # https://stackoverflow.com/a/58827205
19
+        corners = np.arange(1 << dim)[:, None] >> np.arange(dim) & 1
20
+        dots = []
21
+        for c in corners:
22
+            ci = tuple(np.add(c, corner, dtype=int, casting="unsafe"))
23
+            dots.append(np.dot(fract - c, gt.setdefault(ci, np.random.rand(dim))))
24
+        return smerp(dots, fract)
25
+
26
+    def world_natural(self, wr):
27
+        n = 0
28
+        for i in range(wr.r):
29
+            for j in range(wr.r):
30
+                h = 8 + 4 * self.perlin("height", np.add(wr.pos[[0, 2]], (i, j)), np.array([10, 5]))
31
+                for k in range(int(h)):
32
+                    wr.grid[i, k, j] = 1
33
+                    n += 1
34
+        return n
35
+
36
+    def world_min(self, wr):
37
+        wr.add_voxel((0, 0, 0))
38
+        return 1
39
+
40
+    def world_grid(self, wr):
41
+        n = 0
42
+        for i in range(0, wr.r, 2):
43
+            for j in range(0, wr.r, 2):
44
+                for k in range(0, wr.r, 2):
45
+                    wr.grid[i, j, k] = 1
46
+                    n += 1
47
+        return n
48
+
49
+    def world_checker(self, wr):
50
+        n = 0
51
+        for i in range(wr.r):
52
+            for j in range(wr.r):
53
+                for k in range(wr.r):
54
+                    if not (i + j + k) % 2:
55
+                        wr.grid[i, j, k] = 1
56
+                        n += 1
57
+        return n
... ...
@@ -1,21 +1,14 @@
1 1
 from libs import *
2 2
 import entity
3 3
 
4
-def cube_col(axis):
5
-    return vec([
6
-        [0.0, 0.0, 0.0, 1.0],
7
-        [1.0, 0.0, 0.0, 1.0],
8
-        [0.0, 1.0, 0.0, 1.0],
9
-        [0.0, 0.0, 1.0, 1.0]
10
-    ][abs(axis)]) + 0.4 * np.sign(axis, dtype=FLOAT)
11
-
12 4
 class SquareMesh(entity.Entity):
13
-    def __init__(self):
5
+    def __init__(self, **kwargs):
14 6
         super().__init__(
15 7
             np.empty((0, 3), dtype=FLOAT),
16 8
             [0.0, 1.0, 0.0, 1.0],
17 9
             [0.0, 0.0, 0.0],
18
-            mode=GL_TRIANGLES
10
+            mode=GL_TRIANGLES,
11
+            **kwargs
19 12
         )
20 13
         self.faces = []
21 14
 
... ...
@@ -35,5 +28,31 @@ class SquareMesh(entity.Entity):
35 28
         self.verts = np.delete(self.verts, np.s_[3 * ind:3 * (ind + 2)], 0)
36 29
         del self.faces[ind:ind + 2]
37 30
 
31
+    def cube_col(self, fd, axis, corner):
32
+        if not isinstance(fd, int):
33
+            fd = self.grid[*fd]
34
+        match fd:
35
+            case 0:
36
+                return vec([0.0, 0.0, 0.0, 0.0])
37
+            case 1:
38
+                return vec([*np.add(0.5, axis2offset(axis, 0.5)), 1.0])
39
+            case 2:
40
+                top = vec([0.5, 0.5, 1.0, 1.0])
41
+                bottom = vec([0.0, 0.0, 0.5, 1.0])
42
+                match axis:
43
+                    case 3 | -3:
44
+                        return top if corner < 2 else bottom
45
+                    case 1 | -1:
46
+                        return top if not corner % 2 else bottom
47
+                    case 2:
48
+                        return top
49
+                    case -2:
50
+                        return bottom
51
+
38 52
     def col_list(self):
39
-        return np.repeat([cube_col(x[0]) for x in self.faces], 3, 0)
53
+        cl = np.empty((3 * len(self.faces), 4), dtype=FLOAT)
54
+        for s in range(len(self.faces) // 2):
55
+            axis, fd = self.faces[2 * s]
56
+            for (t, c) in enumerate([0, 1, 2, 3, 2, 1] if axis > 0 else [1, 2, 3, 2, 1, 0]):
57
+                cl[6 * s + t] = self.cube_col(fd, axis, c)
58
+        return cl
... ...
@@ -1,12 +1,13 @@
1 1
 from libs import *
2 2
 import camera
3 3
 import entity
4
+import mesh
4 5
 
5
-ARROWS = [pygame.K_UP, pygame.K_DOWN, pygame.K_LEFT, pygame.K_RIGHT, pygame.K_SPACE, pygame.K_LSHIFT]
6
+ARROWS = [pygame.K_RIGHT, pygame.K_LEFT, pygame.K_SPACE, pygame.K_LSHIFT, pygame.K_UP, pygame.K_DOWN]
6 7
 
7 8
 def reshape(w, h):
8 9
     glViewport(0, 0, w, h)
9
-    return w / h
10
+    return (w, h)
10 11
 
11 12
 class Player(camera.Camera):
12 13
     def __init__(self, **kwargs):
... ...
@@ -15,6 +16,8 @@ class Player(camera.Camera):
15 16
         self.rv = vec([0, 0, 0])
16 17
         self.speed = kwargs.get("speed", 0.05)
17 18
         self.hud = {
19
+            "skybox": mesh.SquareMesh(),
20
+            "shl": mesh.SquareMesh(),
18 21
             "crosshair": entity.Entity(
19 22
                 [[-0.03, 0.0, 0.0], [0.03, 0.0, 0.0], [0.0, -0.03, 0.0], [0.0, 0.03, 0.0]],
20 23
                 [1.0, 1.0, 1.0, 1.0], [0.0, 0.0, 0.0], mode=GL_LINES, hud=True
... ...
@@ -24,7 +27,14 @@ class Player(camera.Camera):
24 27
                 YELLOW, [-1.0, 1.0, 0.0], mode=GL_TRIANGLE_STRIP, hud=True
25 28
             ),
26 29
         }
27
-        self.viewport = (640, 480)
30
+        offset = [0, 0, 20]
31
+        for d in range(3):
32
+            roll_vec3(offset, 1)
33
+            for s in range(2):
34
+                self.hud["skybox"].add_sq((d + 1) * (-1) ** s, 40, (-1) ** s * vec(offset), 2)
35
+        self.hud["shl"].draw_mode = GL_LINE_STRIP
36
+        self.hud["shl"].add_sq(2, 0.96, [0, 0, 0], 2)
37
+        self.hud["shl"].verts[:] = self.hud["shl"].verts[[0, 1, 3, 2, 0, 3]]
28 38
 
29 39
     def set_focus(self, f): 
30 40
         pygame.event.set_grab(f)
... ...
@@ -40,11 +50,7 @@ class Player(camera.Camera):
40 50
         self.phi = min(np.pi / 2, max(-np.pi / 2, self.phi + self.sensitivity * dy))
41 51
                 
42 52
     def update_motion(self, pk):
43
-        self.rv = vec([
44
-            pk[pygame.K_RIGHT] - pk[pygame.K_LEFT],
45
-            pk[pygame.K_SPACE] - pk[pygame.K_LSHIFT],
46
-            pk[pygame.K_UP] - pk[pygame.K_DOWN]
47
-        ])
53
+        self.rv = vec([pk[a] - pk[b] for (a, b) in zip(ARROWS[::2], ARROWS[1::2])])
48 54
 
49 55
     def vdof(self):
50 56
         l = self.looking()
... ...
@@ -62,17 +68,26 @@ class Player(camera.Camera):
62 68
                     self.set_focus(False)
63 69
                 elif ev.key in ARROWS:
64 70
                     self.update_motion(pygame.key.get_pressed())
65
-            case pygame.MOUSEMOTION:
66
-                if self.get_focus():
71
+            case pygame.MOUSEMOTION if self.get_focus():
67 72
                 self.mouse_move(ev.rel[0], -ev.rel[1])
68
-            case pygame.MOUSEBUTTONUP:
69
-                if ev.button == 1:
73
+            case pygame.MOUSEBUTTONUP if ev.button == 1:
70 74
                 self.set_focus(True)
71
-            case pygame.MOUSEBUTTONDOWN:
72
-                if self.get_focus():
75
+            case pygame.MOUSEBUTTONDOWN if self.get_focus():
73 76
                 t, n = self.target(scene)
74 77
                 if t:
75 78
                     t.click(ev.button, n)
76 79
             case pygame.WINDOWSIZECHANGED | pygame.WINDOWRESIZED:
77
-                self.aspect = reshape(ev.x, ev.y)
78
-                self.viewport = (ev.x, ev.y)
80
+                self.viewport = reshape(ev.x, ev.y)
81
+
82
+    def draw_hud(self, loc_mvp, wr, perf):
83
+        self.hud["skybox"].pos = self.pos
84
+        c, ap = self.target([wr])
85
+        if c:
86
+            self.hud["shl"].pos = ap[1] + axis2offset(ap[0], 0.52)
87
+            self.hud["shl"].orient[:3, :3] = np.sign(ap[0]) * np.roll(np.identity(3), abs(ap[0]) + 1, 0)
88
+        else:
89
+            self.hud["shl"].pos = vec([0, 0, 0])
90
+        self.hud["fps"].col = GREEN if 0.9 < perf < 1.1 else YELLOW if perf > 0.5 else RED
91
+        self.hud["fps"].update_attr(1)
92
+        for ent in self.hud.values():
93
+            self.draw(ent, loc_mvp)
... ...
@@ -1,23 +1,42 @@
1 1
 from libs import *
2
+import heapq
2 3
 import mesh
3 4
 
4
-def smoothstep(x):
5
-    return x ** 2 * (3 - 2 * x)
6
-
7
-def interp(ab, t):
8
-    return ab[0] + smoothstep(t) * (ab[1] - ab[0])
5
+def intersect_box(ro, rd, c, s):
6
+    m = 1 / rd
7
+    n = m * (ro - c)
8
+    k = np.abs(m) * s
9
+    tn = np.max(-n - k)
10
+    tf = np.min(-n + k)
11
+    if tf > tn and tf > 0:
12
+        return tn, tf
13
+    else:
14
+        return None, None
9 15
 
10 16
 class WorldRegion(mesh.SquareMesh):
11 17
     def __init__(self, size=16):
12 18
         super().__init__()
13 19
         self.grid = np.zeros((size, size, size), dtype=VOXEL_TYPE)
14 20
         self.r = size
21
+        self.dirty = 0
15 22
 
16
-    def add_voxel(self, pos, vt=1):
23
+    def set_voxel(self, pos, vt=0):
17 24
         self.grid[*pos] = vt
18
-
19
-    def del_voxel(self, ind):
20
-        self.grid[*self.faces[ind][1]] = 0
25
+        bp = bool(vt)
26
+        for d in range(3):
27
+            for s in range(2):
28
+                if pos[d] == self.r - 1 and s == 0 or pos[d] == 0 and s == 1:
29
+                    continue
30
+                a = (d + 1) * (-1) ** s
31
+                o = np.array(axis2offset(a), dtype=np.int64)
32
+                bc = bool(self.grid[*(pos + o)])
33
+                if bp ^ bc:
34
+                    a = (bp - bc) * (d + 1)
35
+                    self.add_sq(a, 1, pos + o / 2, pos + (0 if bp else o))
36
+        self.dirty += 1
37
+        if self.dirty > self.r:
38
+            self.dirty = 0
39
+            self.remesh()
21 40
 
22 41
     def remesh(self):
23 42
         offset = vec([0.5, 0.0, 0.0])
... ...
@@ -25,8 +44,7 @@ class WorldRegion(mesh.SquareMesh):
25 44
         prev, curr = 0, 0
26 45
         vl = []
27 46
         for d in range(3):
28
-            offset = np.array([1, 0, 0])
29
-            roll_vec3(offset, d)
47
+            offset = np.array(axis2offset(d + 1))
30 48
             for i in range(self.r):
31 49
                 for j in range(self.r):
32 50
                     for k in range(self.r + 1):
... ...
@@ -50,37 +68,19 @@ class WorldRegion(mesh.SquareMesh):
50 68
                                 self.faces.append(((bp - bc) * (d + 1), p))
51 69
         self.verts = vec(vl)
52 70
 
53
-    def prof_min(self):
54
-        self.add_voxel((0, 0, 0))
55
-        self.remesh()
56
-
57
-    def prof_med(self):
58
-        for i in range(self.r // 2):
59
-            for j in range(self.r // 2):
60
-                for k in range(self.r // 2):
61
-                    self.add_voxel((2 * i, 2 * j, 2 * k))
62
-        self.remesh()
63
-
64
-    def prof_max(self):
65
-        for i in range(self.r):
66
-            for j in range(self.r):
67
-                for k in range(self.r):
68
-                    if not (i + j + k) % 2:
69
-                        self.add_voxel((i, j, k))
70
-        self.remesh()
71
-
72
-    def gen_perlin(self, grads):
73
-        corners = np.array([(i, j, k) for i in range(2) for j in range(2) for k in range(2)])
74
-        n = 0
75
-        for i in range(self.r):
76
-            for j in range(self.r):
77
-                for k in range(self.r):
78
-                    w = np.array((i, j, k)) / self.r
79
-                    d = [np.dot(grads[*c], w) for c in corners]
80
-                    d = [interp(d[2 * k:2 * (k + 1)], w[2]) for k in range(4)]
81
-                    d = [interp(d[:2], w[1]), interp(d[2:4], w[1])]
82
-                    p = interp(d, w[0])
83
-                    if p > 2 * w[1] - 0.5:
84
-                        self.add_voxel((i, j, k))
85
-                        n += 1
86
-        return n
71
+    def raycast(self, ro, rd):
72
+        vr = np.repeat(self.r, 3)
73
+        tn, tf = intersect_box(ro, rd, self.pos + vr / 2 - 0.5, vr / 2)
74
+        if tn is not None:
75
+            if tn < 0:
76
+                tn = 0
77
+            ids = [[(d, i) for d in ((np.arange(self.r) + 0.5) * np.sign(rd[i]) - np.mod(ro[i] + tn * rd[i], 1)) / rd[i]] for i in range(3)]
78
+            for (te, i) in heapq.merge(*ids):
79
+                ts = tn + te
80
+                p = np.astype(np.round(ro + (ts + 1e-2) * rd - self.pos), np.int16)
81
+                if any(p < 0) or any(p >= self.r):
82
+                    break
83
+                elif self.grid[*p]:
84
+                    sa = np.astype((i + 1) * -np.sign(rd[i]), np.int8)
85
+                    return ts, (sa, p)
86
+        return None, None
87 87