OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / 2032608

Make the viewport overlay track Blender's camera
"it seems like it doesnt rotate properly depending on the camera" - correct, and the cause is a horizontal mirror. IFSEngine's view basis is a mirror of Blender's. Its projection computes x_ndc = k * x_view / (-z_view), which sends camera-right to screen-LEFT, and Camera.cs's CreateLookAt(pos, pos - forward, -up) is built around that. An overlay tracking Blender's camera has to undo exactly that mirror, so the viewport now passes flip_x. Diagnosed by comparing against Blender's OWN projection across an orbit rather than by reasoning about handedness. At 0 degrees Blender put the probe at x=493 and we put it at x=18.5 - mirrored about the centre. The two agreed only at 90 and 270 degrees, where the probe sits dead centre, which is exactly why every still image looked fine. The DOCUMENT path must not flip: it matches IFSEngine. Checked rather than assumed, because the gasket I had compared against ifsrender is left-right symmetric and would hide a mirror - light_rods_sample scores 0.611 as-is versus 0.004 flipped. My first fix was wrong in an instructive way. Negating `up` made the orbit test pass, because negating up flips the x basis - but it flips y too, and world +Z ended up below the horizon. The orbit probe sits at z=0 so its screen y never moves, and that test could not see it. Mirroring x alone is the actual fix, and there is now a test for the vertical axis specifically. test_viewport_camera.py checks 24 orbit angles against Blender's projection to within a pixel, asserts the vertical axis separately, and includes a guard that the un-flipped basis FAILS - otherwise the test proves nothing. It needs mathutils so it runs inside Blender rather than in the 67-test suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2032608c7e78a6fe3386e74c7c2ce1c719127122
by plankatron <fleeky@gmail.com> · 2026-08-16T23:20:00.000Z

Changed files (3) +138 -4

blender/ifs_render.py +12 -3
……@@ -97,7 +97,8 @@ def look_at(position, target, up) -> list[list[float]]:
9797
9898
9999def perspective(fov_degrees: float, aspect: float, near: float,
100 engine_fov_quirk: bool = True) -> list[list[float]]:
100 engine_fov_quirk: bool = True,
101 flip_x: bool = False) -> list[list[float]]:
101102 """
102103 `Matrix4x4.CreatePerspectiveFieldOfView` with far = +infinity.
103104
……@@ -114,6 +115,13 @@ def perspective(fov_degrees: float, aspect: float, near: float,
114115 else math.radians(fov_degrees)
115116 y_scale = 1.0 / math.tan(fov * 0.5)
116117 x_scale = y_scale / aspect
118 if flip_x:
119 # Mirrors the HORIZONTAL axis only. IFSEngine's view basis is a horizontal
120 # mirror of Blender's - its x_ndc = k*x_view/(-z_view) sends camera-right
121 # to screen-left - so an overlay tracking Blender's camera has to undo
122 # exactly that, and only that. Negating `up` instead flips both axes and
123 # puts world +Z below the horizon.
124 x_scale = -x_scale
117125 return [
118126 [x_scale, 0.0, 0.0, 0.0],
119127 [0.0, y_scale, 0.0, 0.0],
……@@ -128,7 +136,7 @@ def matmul(a: list[list[float]], b: list[list[float]]) -> list[list[float]]:
128136
129137
130138def view_projection(position, forward, up, fov_degrees=60.0, near=0.001,
131 engine_fov_quirk=True):
139 engine_fov_quirk=True, flip_x=False):
132140 """
133141 The engine's view-projection, flattened in .NET's row-major memory order.
134142
……@@ -138,7 +146,8 @@ def view_projection(position, forward, up, fov_degrees=60.0, near=0.001,
138146 """
139147 target = (position[0] - forward[0], position[1] - forward[1], position[2] - forward[2])
140148 view = look_at(position, target, (-up[0], -up[1], -up[2]))
141 combined = matmul(view, perspective(fov_degrees, 1.0, near, engine_fov_quirk))
149 combined = matmul(view, perspective(fov_degrees, 1.0, near, engine_fov_quirk,
150 flip_x))
142151 return tuple(value for row in combined for value in row)
143152
144153
blender/ifs_viewport.py +9 -1
……@@ -313,6 +313,7 @@ class Session:
313313 world = region_3d.view_matrix.inverted()
314314 eye = world.translation
315315 forward = -Vector(world.col[2][:3]) # Blender cameras look down -Z
316
316317 up = Vector(world.col[1][:3])
317318 # Horizontal FOV from the viewport's focal length against Blender's 36mm
318319 # sensor. The kernel's projection uses aspect 1 and applies width/height
……@@ -320,7 +321,14 @@ class Session:
320321 fov = math.degrees(2.0 * math.atan(18.0 / max(lens, 1.0)))
321322 return ifs_render.view_projection(
322323 (eye.x, eye.y, eye.z), (forward.x, forward.y, forward.z),
323 (up.x, up.y, up.z), fov_degrees=fov, engine_fov_quirk=False)
324 (up.x, up.y, up.z), fov_degrees=fov, engine_fov_quirk=False,
325 # IFSEngine's view basis is a HORIZONTAL mirror of Blender's - its
326 # x_ndc = k*x_view/(-z_view) sends camera-right to screen-left - so an
327 # overlay tracking Blender's camera must undo that, and only that.
328 # Orbiting is what exposes it: still images agree at screen centre.
329 # The DOCUMENT path must NOT flip; it matches IFSEngine, checked
330 # against ifsrender at 0.611 as-is versus 0.004 mirrored.
331 flip_x=True)
324332
325333 # How much the view-projection may drift before the accumulation is thrown
326334 # away. Exact comparison restarts on floating-point noise and on Blender's
blender/test_viewport_camera.py +117 -0
……@@ -0,0 +1,117 @@
1"""
2Tests for the viewport camera basis.
3
4These exist because a mirror in the view basis is invisible in a still image of a
5symmetric fractal and invisible at the centre of the screen, so it survived every
6check until someone orbited and said it "doesn't rotate properly". The previous
7camera test asserted the mirror as CORRECT.
8
9Ground truth here is Blender's own projection, not my reasoning about handedness:
10the viewport overlay has to land where Blender would put the same world point, or
11it does not track the scene.
12
13These need mathutils, so they run inside Blender:
14 blender -b --python-expr "import unittest,sys; sys.path.insert(0,'blender'); \\
15 unittest.main(module='test_viewport_camera', argv=['x'])"
16"""
17
18import math
19import unittest
20
21try:
22 from mathutils import Matrix, Vector
23except ImportError: # pragma: no cover - only meaningful inside Blender
24 Matrix = Vector = None
25
26import ifs_render
27
28W = H = 512
29LENS = 50.0
30
31
32def _orbit_view(degrees, radius=3.0):
33 """A camera orbiting the origin at `degrees`, world +Z up, looking inward."""
34 a = math.radians(degrees)
35 eye = Vector((radius * math.sin(a), -radius * math.cos(a), 0.0))
36 forward = (Vector((0, 0, 0)) - eye).normalized()
37 right = forward.cross(Vector((0, 0, 1))).normalized()
38 up = right.cross(forward)
39 world = Matrix(((right.x, up.x, -forward.x, eye.x),
40 (right.y, up.y, -forward.y, eye.y),
41 (right.z, up.z, -forward.z, eye.z),
42 (0, 0, 0, 1)))
43 return world.inverted()
44
45
46def _blender_screen(view, point):
47 fov = 2.0 * math.atan(18.0 / LENS)
48 f = 1.0 / math.tan(fov / 2)
49 n, fr = 0.1, 100.0
50 proj = Matrix(((f, 0, 0, 0), (0, f, 0, 0),
51 (0, 0, (fr + n) / (n - fr), 2 * fr * n / (n - fr)),
52 (0, 0, -1, 0)))
53 v = proj @ view @ Vector((point[0], point[1], point[2], 1.0))
54 if v.w <= 0:
55 return None
56 return ((v.x / v.w + 1) * 0.5 * W, (v.y / v.w + 1) * 0.5 * H)
57
58
59def _our_screen(view, point):
60 """Exactly what ifs_viewport._view_projection does, including the negated up."""
61 world = view.inverted()
62 eye = world.translation
63 forward = -Vector(world.col[2][:3])
64 up = Vector(world.col[1][:3])
65 fov = math.degrees(2.0 * math.atan(18.0 / LENS))
66 vp = ifs_render.view_projection(
67 (eye.x, eye.y, eye.z), (forward.x, forward.y, forward.z),
68 (up.x, up.y, up.z), fov_degrees=fov, engine_fov_quirk=False, flip_x=True)
69 return ifs_render.project_point(vp, point, W, H)
70
71
72@unittest.skipIf(Matrix is None, "needs mathutils (run inside Blender)")
73class ViewportTracksBlender(unittest.TestCase):
74 # project_point subtracts half a pixel, as the kernel does.
75 TOLERANCE = 1.0
76
77 def test_matches_blenders_projection_around_a_full_orbit(self):
78 probe = (1.0, 0.0, 0.0) # asymmetric, or a mirror is invisible
79 for degrees in range(0, 360, 15):
80 view = _orbit_view(degrees)
81 expected = _blender_screen(view, probe)
82 actual = _our_screen(view, probe)
83 with self.subTest(orbit=degrees):
84 self.assertIsNotNone(actual, "point was discarded")
85 self.assertIsNotNone(expected)
86 self.assertLess(abs(expected[0] - actual[0]), self.TOLERANCE)
87 self.assertLess(abs(expected[1] - actual[1]), self.TOLERANCE)
88
89 def test_a_mirrored_basis_would_fail_this(self):
90 # Guards the guard: with the up negation removed the test must break, or
91 # it is not actually testing for the mirror.
92 view = _orbit_view(0)
93 world = view.inverted()
94 eye = world.translation
95 forward = -Vector(world.col[2][:3])
96 up = Vector(world.col[1][:3])
97 fov = math.degrees(2.0 * math.atan(18.0 / LENS))
98 vp = ifs_render.view_projection( # flip_x omitted - the old bug
99 (eye.x, eye.y, eye.z), (forward.x, forward.y, forward.z),
100 (up.x, up.y, up.z), fov_degrees=fov, engine_fov_quirk=False)
101 mirrored = ifs_render.project_point(vp, (1.0, 0.0, 0.0), W, H)
102 correct = _our_screen(view, (1.0, 0.0, 0.0))
103
104 self.assertGreater(abs(mirrored[0] - correct[0]), 100.0,
105 "the two bases are indistinguishable, so this proves nothing")
106
107 def test_vertical_axis_is_not_flipped(self):
108 # The orbit probe sits at z=0, so its screen y never moves and a vertical
109 # flip hides from that test completely. This is the one that catches it.
110 view = _orbit_view(0)
111 high = _our_screen(view, (0.0, 0.0, 1.0))
112 low = _our_screen(view, (0.0, 0.0, -1.0))
113 self.assertGreater(high[1], low[1], "world +Z must be up on screen")
114
115
116if __name__ == "__main__":
117 unittest.main()