OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / 88335c9

The viewport could never render sharper than half resolution
The internal render resolution is divided down while the camera moves, because every frame throws its samples away and one dispatch has to cover the whole image. The comment said full resolution returned once the view settled. It did not: the settled tier was DIVISOR 2, so the overlay was permanently half resolution per axis - a quarter of the pixels - scaled up. That was a hard ceiling on how sharp the viewport could look, and since the depth texture shares that resolution it is also why the occlusion edges added earlier were blocky. Nothing caught it because the code was self-consistent and only the comment was wrong, and a permanently soft overlay reads as a design choice rather than a bug. Three tiers now: 1/4 per axis while moving, 1/2 for the first frames after stopping, then 1:1. The middle tier is worth keeping - it gives a readable image within a few frames of stopping, where jumping straight to 1:1 leaves a long noisy period. Each step resizes and so restarts accumulation, which is the price of the finer grid; the final tier then accumulates indefinitely. The trade is real and worth stating: at 1:1 each frame's samples spread over four times the pixels, so it starts grainier and converges slower. Iterations/frame is the knob for that. The schedule moved into divisor_for() so it can be asserted without a GPU. The tests check the PROPERTY the comment claims rather than the constants: a settled view must reach 1:1, motion must be coarser than rest, the schedule must be monotonic - a non-monotonic one would keep resizing and discarding accumulation while the user sits still - and it must step rather than jump. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
88335c9fa5b0c4e41a0827af23921551f16c470f
by plankatron <fleeky@gmail.com> · 2026-08-17T22:13:12.000Z

Changed files (2) +92 -4

blender/ifs_viewport.py +24 -4
……@@ -457,11 +457,32 @@ class Session:
457457 # has to cover the whole image and the result is sparse noise. Rotation shows
458458 # this worst - mouselook changes the view every single frame, where translation
459459 # often pauses. Rendering at a coarser internal resolution during motion puts
460 # the same budget over a quarter of the pixels, so it stays readable, and full
461 # resolution returns as soon as the view settles.
460 # the same budget over a quarter of the pixels, so it stays readable.
461 #
462 # Three tiers, because two never reached 1:1. The comment here used to claim full
463 # resolution returned when the view settled, but STILL_DIVISOR is 2 - so the
464 # overlay was permanently HALF resolution per axis, a quarter of the pixels,
465 # scaled up. That was the ceiling on how sharp the viewport could ever look, and
466 # it also coarsened the depth texture, which is why occlusion edges were blocky.
467 #
468 # The middle tier is worth keeping: it gives a readable image within a few frames
469 # of stopping, where jumping straight to 1:1 would leave a long noisy period. Each
470 # step resizes and so restarts accumulation, which is the price of the sharper
471 # grid - the final tier then accumulates indefinitely.
462472 MOTION_DIVISOR = 4
463473 STILL_DIVISOR = 2
474 FULL_DIVISOR = 1
464475 STILL_FRAMES = 3
476 SHARPEN_FRAMES = 24
477
478 @classmethod
479 def divisor_for(cls, still_frames):
480 """The internal-resolution divisor for a run of unchanged frames."""
481 if still_frames < cls.STILL_FRAMES:
482 return cls.MOTION_DIVISOR
483 if still_frames < cls.SHARPEN_FRAMES:
484 return cls.STILL_DIVISOR
485 return cls.FULL_DIVISOR
465486
466487 def draw(self, context):
467488 settings = getattr(context.scene, "ifs", None)
……@@ -476,8 +497,7 @@ class Session:
476497 moved = self._view_moved(view_proj)
477498 self._still_frames = 0 if moved else getattr(self, "_still_frames", 0) + 1
478499
479 divisor = (self.MOTION_DIVISOR if self._still_frames < self.STILL_FRAMES
480 else self.STILL_DIVISOR)
500 divisor = self.divisor_for(self._still_frames)
481501 region = context.region
482502 resized = self._resize(max(region.width // divisor, 16),
483503 max(region.height // divisor, 16))
blender/test_viewport_resolution.py +68 -0
……@@ -0,0 +1,68 @@
1"""
2Proves the viewport overlay actually reaches 1:1.
3
4The internal render resolution is divided down while the camera moves, because every
5frame throws its samples away and one dispatch has to cover the whole image. The
6comment said full resolution returned once the view settled. It did not: the settled
7tier was DIVISOR 2, so the overlay was permanently half resolution per axis - a
8quarter of the pixels - scaled up. That was the ceiling on how sharp it could look,
9and it coarsened the depth texture too, which is why occlusion edges were blocky.
10
11Nothing caught it because the code was self-consistent and only the comment was
12wrong, and a permanently soft overlay looks like a design choice rather than a bug.
13So this asserts the property the comment claims, not the constants themselves.
14
15Runs inside Blender (ifs_viewport imports bpy and gpu):
16 blender -b --factory-startup -noaudio --python blender/test_viewport_resolution.py
17"""
18
19import os
20import sys
21import unittest
22
23sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
24
25
26class ViewportResolution(unittest.TestCase):
27 @classmethod
28 def setUpClass(cls):
29 try:
30 import ifs_viewport
31 except ImportError as ex:
32 raise unittest.SkipTest(f"needs Blender's bpy/gpu modules ({ex})")
33 cls.Session = ifs_viewport.Session
34
35 def test_a_settled_view_eventually_renders_at_full_resolution(self):
36 """The whole point: without this the overlay can never be sharper than half."""
37 self.assertEqual(1, self.Session.divisor_for(10_000),
38 "a long-settled view still renders below 1:1, so the overlay "
39 "is permanently softer than the screen")
40
41 def test_motion_is_coarser_than_rest(self):
42 """The coarse tier is what keeps mouselook readable; losing it is also a bug."""
43 moving = self.Session.divisor_for(0)
44 settled = self.Session.divisor_for(10_000)
45 self.assertGreater(moving, settled)
46
47 def test_resolution_never_gets_coarser_as_the_view_settles(self):
48 """
49 Monotonic by frame count. A non-monotonic schedule would resize - and so throw
50 away the accumulation - repeatedly while the user sits still.
51 """
52 divisors = [self.Session.divisor_for(n) for n in range(0, 200)]
53 for earlier, later in zip(divisors, divisors[1:]):
54 self.assertLessEqual(later, earlier,
55 "the divisor increased as the view settled")
56
57 def test_it_steps_rather_than_jumping_straight_to_full(self):
58 """
59 The middle tier gives a readable image within a few frames of stopping;
60 jumping straight to 1:1 leaves a long noisy period instead.
61 """
62 seen = {self.Session.divisor_for(n) for n in range(0, 200)}
63 self.assertGreaterEqual(len(seen), 3,
64 f"expected a stepped schedule, got tiers {sorted(seen)}")
65
66
67if __name__ == "__main__":
68 unittest.main(argv=sys.argv[:1], exit=False, verbosity=2)