basalt-win32 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,153 @@
1
+ #include "Win32PeerClip.h"
2
+
3
+ #include "RnWin32View.h"
4
+
5
+ #include <algorithm>
6
+ #include <cmath>
7
+
8
+ namespace basalt::win32 {
9
+
10
+ namespace {
11
+
12
+ RECT boxOf(double x, double y, double width, double height) {
13
+ RECT rect{};
14
+ rect.left = static_cast<LONG>(std::lround(x));
15
+ rect.top = static_cast<LONG>(std::lround(y));
16
+ rect.right = rect.left + static_cast<LONG>(std::lround(width));
17
+ rect.bottom = rect.top + static_cast<LONG>(std::lround(height));
18
+ return rect;
19
+ }
20
+
21
+ bool isEmpty(const RECT &rect) {
22
+ return rect.right <= rect.left || rect.bottom <= rect.top;
23
+ }
24
+
25
+ RECT intersect(const RECT &a, const RECT &b) {
26
+ RECT out{};
27
+ out.left = std::max(a.left, b.left);
28
+ out.top = std::max(a.top, b.top);
29
+ out.right = std::min(a.right, b.right);
30
+ out.bottom = std::min(a.bottom, b.bottom);
31
+ if (isEmpty(out)) {
32
+ return RECT{0, 0, 0, 0};
33
+ }
34
+ return out;
35
+ }
36
+
37
+ // Whether this view puts something over the whole of its own box.
38
+ //
39
+ // A background colour with any alpha at all, and nothing else -- see the
40
+ // header for why the bar is "not transparent" rather than "opaque", and why a
41
+ // transformed view is left alone instead of being approximated by its bounding
42
+ // box.
43
+ bool covers(const RnWin32View *view) {
44
+ if (!view->hasBackgroundColor() || view->hasTransform()) {
45
+ return false;
46
+ }
47
+ if (view->opacity() <= 0.0f) {
48
+ return false;
49
+ }
50
+ return view->backgroundColor()[3] > 0.0f;
51
+ }
52
+
53
+ struct Walk {
54
+ const RnWin32View *target{nullptr};
55
+ PeerClip *out{nullptr};
56
+ // Set when the target has been reached, which is what divides the tree into
57
+ // "painted before it", which cannot cover it, and "painted after it", which
58
+ // can. One flag is enough because painting order is a single sequence.
59
+ bool seen{false};
60
+ };
61
+
62
+ // `origin` is where this view's parent puts its children, already scrolled.
63
+ void visit(RnWin32View *view, double originX, double originY, const RECT &clip, Walk &walk) {
64
+ if (view == nullptr || view->hidden()) {
65
+ return;
66
+ }
67
+
68
+ const double x = originX + view->frame().x;
69
+ const double y = originY + view->frame().y;
70
+ const RECT box = boxOf(x, y, view->frame().width, view->frame().height);
71
+
72
+ if (view == walk.target) {
73
+ const RECT visible = intersect(box, clip);
74
+ if (isEmpty(visible)) {
75
+ return;
76
+ }
77
+ walk.out->visible = true;
78
+ walk.out->bounds = box;
79
+ walk.out->clip = visible;
80
+ // Its own subtree is not walked: a child of a `<TextInput>` is inside the
81
+ // field rather than over it, and the field has none in any real tree.
82
+ walk.seen = true;
83
+ return;
84
+ }
85
+
86
+ if (walk.seen && covers(view)) {
87
+ const RECT over = intersect(box, clip);
88
+ if (!isEmpty(over)) {
89
+ walk.out->occluders.push_back(over);
90
+ }
91
+ }
92
+
93
+ RECT childClip = clip;
94
+ if (view->clipsChildren()) {
95
+ childClip = intersect(clip, box);
96
+ if (isEmpty(childClip)) {
97
+ // Nothing inside can be seen, and nothing inside can cover anything
98
+ // either -- but the target may still be in there, and if it is then it is
99
+ // off screen, which is what not finding it already means.
100
+ return;
101
+ }
102
+ }
103
+
104
+ const double childX = x - view->scrollX();
105
+ const double childY = y - view->scrollY();
106
+ for (RnWin32View *child : view->childrenInPaintOrder()) {
107
+ visit(child, childX, childY, childClip, walk);
108
+ }
109
+ }
110
+
111
+ } // namespace
112
+
113
+ PeerClip peerClip(RnWin32View *view, RnWin32View *root) {
114
+ PeerClip result{};
115
+ if (view == nullptr || root == nullptr) {
116
+ return result;
117
+ }
118
+
119
+ // The root's own frame is not part of these coordinates: the surface root is
120
+ // the client area, and a peer's position is measured from its top-left. So
121
+ // the walk starts inside it rather than at it, which also means a `view` that
122
+ // *is* the root is not a field and comes back invisible.
123
+ if (root->hidden()) {
124
+ return result;
125
+ }
126
+
127
+ // Unbounded unless the root says otherwise, which is the rule every other
128
+ // ancestor is under. Clipping to the client area regardless would be defen-
129
+ // sible and is not done here: a surface that has never been laid out has a
130
+ // zero frame, and "every field is invisible" is a worse answer than "a field
131
+ // outside the window is placed outside the window", which USER32 handles.
132
+ constexpr LONG kUnbounded = 1 << 28;
133
+ RECT clip{-kUnbounded, -kUnbounded, kUnbounded, kUnbounded};
134
+ if (root->clipsChildren()) {
135
+ clip = boxOf(0.0, 0.0, root->frame().width, root->frame().height);
136
+ if (isEmpty(clip)) {
137
+ return result;
138
+ }
139
+ }
140
+ Walk walk{view, &result, false};
141
+ const double childX = -static_cast<double>(root->scrollX());
142
+ const double childY = -static_cast<double>(root->scrollY());
143
+ for (RnWin32View *child : root->childrenInPaintOrder()) {
144
+ visit(child, childX, childY, clip, walk);
145
+ }
146
+
147
+ if (!result.visible) {
148
+ return PeerClip{};
149
+ }
150
+ return result;
151
+ }
152
+
153
+ } // namespace basalt::win32
@@ -0,0 +1,92 @@
1
+ // Where a child-window peer is allowed to paint.
2
+ //
3
+ // A `<TextInput>` on this host is a real EDIT control, so it is a child window
4
+ // rather than something Direct2D draws. That buys the IME, the clipboard and
5
+ // every binding a Windows user expects, and it costs the one thing every other
6
+ // view gets for free: a place in the painting order. A child window is drawn by
7
+ // USER32 after the parent's client area, so it sits above everything the React
8
+ // tree drew, whatever the tree says. Two things follow, and a person sees both:
9
+ //
10
+ // - A field in a `<ScrollView>` does not clip to it. Scroll the list until
11
+ // the field is half out and the half that should be gone goes on being
12
+ // drawn over the list's neighbour.
13
+ // - A field cannot be covered. Open a modal over a pane that has one and the
14
+ // field floats on top of the modal, at full brightness, still editable.
15
+ //
16
+ // Both are the same missing fact -- which pixels of the field are still the
17
+ // field's -- and Windows has somewhere to put it: `SetWindowRgn`. A window with
18
+ // a region is displayed only inside it, and clicks outside it fall through to
19
+ // the parent. So the job is to work the region out from the view tree, which is
20
+ // what this file is: pure geometry over `RnWin32View`, with no window, no
21
+ // control and no React Native in it, so that it can be tested with none of
22
+ // them.
23
+ //
24
+ // ## What counts as covering it
25
+ //
26
+ // Clipping is exact: a clipping ancestor's box is a rectangle and the
27
+ // intersection of rectangles is the answer.
28
+ //
29
+ // Occlusion is not, and cannot be. The right behaviour for a field under
30
+ // kino's modal scrim -- `#000000A0` over the whole window -- is for the field
31
+ // to be *dimmed*, and a child window cannot be dimmed by something painted into
32
+ // its parent: the two are composited by USER32, in that order, with no blend to
33
+ // take part in. The choice is therefore between showing the field undimmed and
34
+ // hiding it, and hiding it is the better wrong answer: an undimmed field over a
35
+ // modal is the bug people report, while a field that goes quiet under a scrim
36
+ // reads as part of the modal.
37
+ //
38
+ // So anything painted after the field that is not fully transparent counts as
39
+ // covering it. That is deliberately more eager than "opaque": a 63%-black
40
+ // backdrop blends to something no child window can match, so there is nothing
41
+ // to be gained by letting it show through at 100%.
42
+ //
43
+ // What does *not* count: a view with no background at all, which is every
44
+ // layout wrapper in a real tree and the reason this rule produces no false
45
+ // positives in practice, and a transformed view, whose box is not a rectangle
46
+ // in these coordinates and which is therefore left alone rather than guessed
47
+ // at. A `<Text>` drawn over a field is also left alone -- glyphs are not a box.
48
+ //
49
+ // ## Transforms
50
+ //
51
+ // Ignored throughout, as `Win32TextInputManager` already ignores them when it
52
+ // places the peer: a window cannot be rotated, so a transformed field's peer
53
+ // sits where the untransformed one would, and clipping it to a rotated
54
+ // ancestor's bounding box would only move the error around.
55
+
56
+ #pragma once
57
+
58
+ #include <windows.h>
59
+
60
+ #include <vector>
61
+
62
+ namespace basalt::win32 {
63
+
64
+ class RnWin32View;
65
+
66
+ // Where `view`'s peer may paint, all in the surface root's coordinates.
67
+ struct PeerClip {
68
+ // False when the view is not on screen at all: hidden, detached, or scrolled
69
+ // entirely out of a clipping ancestor. Nothing else in the struct means
70
+ // anything then.
71
+ bool visible{false};
72
+
73
+ // The view's own box, unclipped. This is where the control goes -- a control
74
+ // narrowed to its visible part would lay its text out in the wrong width.
75
+ RECT bounds{};
76
+
77
+ // `bounds` narrowed by every clipping ancestor. Never empty when `visible`.
78
+ RECT clip{};
79
+
80
+ // Boxes painted after the view that cover part of it, already narrowed to
81
+ // their own clipping ancestors. Each is taken out of the region.
82
+ std::vector<RECT> occluders{};
83
+ };
84
+
85
+ // Walks the tree from `root` once, in painting order, and answers for `view`.
86
+ //
87
+ // `root` is the surface root, whose coordinates are the window's client
88
+ // coordinates on this host. A view that is not under `root` comes back
89
+ // invisible, which is what a peer between a Remove and its Delete looks like.
90
+ PeerClip peerClip(RnWin32View *view, RnWin32View *root);
91
+
92
+ } // namespace basalt::win32
@@ -4,28 +4,49 @@
4
4
 
5
5
  #include <react/utils/RunLoopObserverManager.h>
6
6
 
7
+ #include <chrono>
8
+
7
9
  namespace basalt {
8
10
 
9
- int runMessageLoopWithBeat(
10
- std::shared_ptr<facebook::react::RunLoopObserverManager> manager) {
11
+ int runMessageLoop(const std::function<void()> &beat) {
11
12
  MSG message{};
13
+
12
14
  for (;;) {
13
- // Drain everything queued before beating. Beating between each message
14
- // would be correct but wasteful, and beating *after* the queue is empty is
15
- // what "BeforeWaiting" means -- the frame has settled, and whatever the
16
- // messages produced is now worth flushing to JavaScript in one go.
15
+ // Drain what is queued before beating. Beating between each message would
16
+ // be correct but wasteful, and beating *after* the queue is empty is what
17
+ // "BeforeWaiting" means -- the frame has settled, and whatever the messages
18
+ // produced is now worth flushing to JavaScript in one go.
19
+ //
20
+ // Bounded, though: "after the queue is empty" is a moment that never comes
21
+ // in an app that paints continuously, and an event waiting for it waits
22
+ // forever. See the header for what that cost -- a button that usually
23
+ // worked.
24
+ auto since = std::chrono::steady_clock::now();
25
+ int drained = 0;
17
26
  while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) {
18
27
  if (message.message == WM_QUIT) {
19
28
  return static_cast<int>(message.wParam);
20
29
  }
21
30
  TranslateMessage(&message);
22
31
  DispatchMessageW(&message);
23
- }
24
32
 
25
- if (manager != nullptr) {
26
- manager->onRender();
33
+ // The count is checked first because it is free, and the clock only
34
+ // every `kBeatEveryMessages` messages, so a busy drain does not spend
35
+ // itself in `steady_clock::now`.
36
+ if (++drained < kBeatEveryMessages) {
37
+ continue;
38
+ }
39
+ drained = 0;
40
+ const auto now = std::chrono::steady_clock::now();
41
+ if (now - since < std::chrono::milliseconds(kBeatEveryMs)) {
42
+ continue;
43
+ }
44
+ since = now;
45
+ beat();
27
46
  }
28
47
 
48
+ beat();
49
+
29
50
  // Block until something arrives. MsgWaitForMultipleObjectsEx rather than
30
51
  // GetMessage, because the beat above may itself have posted work -- a mount
31
52
  // marshalled from the JS thread arrives as a posted message -- and
@@ -45,4 +66,13 @@ int runMessageLoopWithBeat(
45
66
  }
46
67
  }
47
68
 
69
+ int runMessageLoopWithBeat(
70
+ std::shared_ptr<facebook::react::RunLoopObserverManager> manager) {
71
+ return runMessageLoop([&manager] {
72
+ if (manager != nullptr) {
73
+ manager->onRender();
74
+ }
75
+ });
76
+ }
77
+
48
78
  } // namespace basalt
@@ -24,9 +24,32 @@
24
24
  //
25
25
  // So this is a loop rather than an observer, and the host calls it instead of
26
26
  // writing its own.
27
+ //
28
+ // ## "Until it comes up empty" is not enough on its own
29
+ //
30
+ // That rule assumes the queue eventually comes up empty, and in a real app it
31
+ // does not. kino's editor, whose preview is a Skia `<Canvas>`, posts work from
32
+ // the JavaScript thread around 250 times a second and invalidates its window
33
+ // around 120 -- so `PeekMessage` keeps answering, the drain keeps draining,
34
+ // and the beat below it waits for a gap that never comes. The queue is a
35
+ // treadmill and the beat is at the end of it.
36
+ //
37
+ // What that looks like from outside is the worst kind of bug: a button that
38
+ // usually works. The touch was hit-tested, the emitter was found, the event
39
+ // was enqueued -- and then sat in the queue until some accident of timing let
40
+ // the loop reach the bottom. Measured on that app, one scripted tap in three
41
+ // did nothing at all, and a real click, which lands later in a run and under
42
+ // more load, did nothing far more often than that.
43
+ //
44
+ // So the drain is bounded: the beat also happens every `kBeatEveryMs` and
45
+ // every `kBeatEveryMessages`, whichever comes first. Neither number is a
46
+ // tuning parameter to get right -- they are an upper bound on how long an
47
+ // event may sit, and the cost of beating early is one pass over an empty
48
+ // queue.
27
49
 
28
50
  #pragma once
29
51
 
52
+ #include <functional>
30
53
  #include <memory>
31
54
 
32
55
  namespace facebook::react {
@@ -35,8 +58,24 @@ class RunLoopObserverManager;
35
58
 
36
59
  namespace basalt {
37
60
 
38
- // Runs until WM_QUIT, inducing the beat each time the queue empties. Returns
39
- // the exit code from WM_QUIT.
61
+ // How long an event may wait, and how many messages may go by, before the
62
+ // beat happens whether or not the queue has emptied. Eight milliseconds is
63
+ // half a frame at sixty hertz: an event enqueued at the worst moment still
64
+ // reaches JavaScript inside the frame it belongs to.
65
+ inline constexpr unsigned kBeatEveryMs = 8;
66
+ inline constexpr int kBeatEveryMessages = 64;
67
+
68
+ // Runs until WM_QUIT, calling `beat` each time the queue empties -- and, on a
69
+ // queue that does not empty, at least every `kBeatEveryMs`. Returns the exit
70
+ // code from WM_QUIT.
71
+ //
72
+ // The beat is a parameter rather than the manager itself because the bound
73
+ // above is the whole point of this file and a test needs to be able to count
74
+ // beats. `beat` may post WM_QUIT, which is how a test ends the loop.
75
+ int runMessageLoop(const std::function<void()> &beat);
76
+
77
+ // The host's own entry point: the loop above, beating React Native's event
78
+ // queue. A null manager runs the loop with nothing to beat.
40
79
  int runMessageLoopWithBeat(std::shared_ptr<facebook::react::RunLoopObserverManager> manager);
41
80
 
42
81
  } // namespace basalt
@@ -6,6 +6,7 @@
6
6
  #include <utility>
7
7
 
8
8
  #include "PlatformServices.h"
9
+ #include "Win32UiTimer.h"
9
10
 
10
11
  #include <react/renderer/components/scrollview/ScrollEvent.h>
11
12
  #include <react/renderer/components/scrollview/ScrollViewEventEmitter.h>
@@ -437,34 +438,6 @@ void Win32ScrollViewManager::endWheelDrag(Tag tag, std::uint64_t generation) {
437
438
  // Offset, events and state
438
439
  // ---------------------------------------------------------------------------
439
440
 
440
- namespace {
441
-
442
- // Which manager and view a timer is animating. A TIMERPROC carries no state, so
443
- // the id is the key -- and the map is the reason `stopAnimation` must run before
444
- // an entry goes, exactly as the display link does on AppKit.
445
- std::unordered_map<UINT_PTR, std::pair<Win32ScrollViewManager *, facebook::react::Tag>> &
446
- animations() {
447
- static std::unordered_map<UINT_PTR, std::pair<Win32ScrollViewManager *, facebook::react::Tag>>
448
- value;
449
- return value;
450
- }
451
-
452
- } // namespace
453
-
454
- void CALLBACK Win32ScrollViewManager::onAnimationTimer(HWND /*hwnd*/,
455
- UINT /*message*/,
456
- UINT_PTR id,
457
- DWORD now) {
458
- const auto it = animations().find(id);
459
- if (it == animations().end()) {
460
- KillTimer(nullptr, id);
461
- return;
462
- }
463
- Win32ScrollViewManager *manager = it->second.first;
464
- const facebook::react::Tag tag = it->second.second;
465
- manager->advanceAnimation(tag, static_cast<double>(now) / 1000.0);
466
- }
467
-
468
441
  void Win32ScrollViewManager::scrollTowards(Entry &entry, double x, double y, bool animated) {
469
442
  stopAnimation(entry);
470
443
  if (!animated || !entry.animation.start(entry.offsetX, entry.offsetY, x, y)) {
@@ -474,14 +447,21 @@ void Win32ScrollViewManager::scrollTowards(Entry &entry, double x, double y, boo
474
447
  // Sixteen milliseconds: this host's choreographer runs on a timer of the same
475
448
  // period, and matching it keeps a scroll and an animation stepping together
476
449
  // rather than beating against each other.
477
- entry.animationTimer = SetTimer(nullptr, 0, 16, onAnimationTimer);
450
+ //
451
+ // The callback carries the tag rather than being looked up from a timer id,
452
+ // which is what a TIMERPROC forced and what the map here used to be for.
453
+ const facebook::react::Tag tag = entry.tag;
454
+ entry.animationTimer = scheduleUiTimer(16, true, [this, tag] {
455
+ advanceAnimation(tag, static_cast<double>(GetTickCount64()) / 1000.0);
456
+ });
478
457
  if (entry.animationTimer == 0) {
458
+ // No loop to animate on -- the unit tests, which install none. Land on the
459
+ // target rather than sit half-way to it forever.
479
460
  entry.animation.stop();
480
461
  applyOffset(entry, x, y, true);
481
462
  return;
482
463
  }
483
464
  entry.animationLastMillis = 0;
484
- animations()[entry.animationTimer] = {this, entry.tag};
485
465
  }
486
466
 
487
467
  bool Win32ScrollViewManager::settleOnSnapPoint(Entry &entry, double velocityY) {
@@ -500,8 +480,9 @@ bool Win32ScrollViewManager::settleOnSnapPoint(Entry &entry, double velocityY) {
500
480
  void Win32ScrollViewManager::stopAnimation(Entry &entry) {
501
481
  entry.animation.stop();
502
482
  if (entry.animationTimer != 0) {
503
- KillTimer(nullptr, entry.animationTimer);
504
- animations().erase(entry.animationTimer);
483
+ // Called from inside the animation's own callback when the scroll
484
+ // finishes, which `cancelUiTimer` is written to accept.
485
+ cancelUiTimer(entry.animationTimer);
505
486
  entry.animationTimer = 0;
506
487
  }
507
488
  }
@@ -153,11 +153,13 @@ class Win32ScrollViewManager {
153
153
 
154
154
  // An animated `scrollTo`. Windows models no fling -- a wheel supplies no
155
155
  // velocity -- but an animated scroll is given its target, so the argument
156
- // that rules out momentum does not rule this out. Driven by a thread timer,
157
- // which needs no window: the scroll manager has a tag, not an HWND.
156
+ // that rules out momentum does not rule this out. Driven by a
157
+ // win32/Win32UiTimer.h timer rather than by `SetTimer`, for the reason
158
+ // that header gives: a WM_TIMER at sixty hertz stops arriving in an app
159
+ // that repaints continuously, which is to say mid-scroll.
158
160
  ScrollSnapConfig snap;
159
161
  ScrollAnimation animation;
160
- UINT_PTR animationTimer{0};
162
+ std::uint64_t animationTimer{0};
161
163
  unsigned long long animationLastMillis{0};
162
164
 
163
165
  std::shared_ptr<const facebook::react::ScrollViewShadowNode::ConcreteState> state;
@@ -236,9 +238,6 @@ class Win32ScrollViewManager {
236
238
  void stopAnimation(Entry &entry);
237
239
  void advanceAnimation(facebook::react::Tag tag, double seconds);
238
240
 
239
- // The timer procedure cannot carry a pointer, so it looks the animation up by
240
- // timer id. Static because `SetTimer` calls a plain function.
241
- static void CALLBACK onAnimationTimer(HWND hwnd, UINT message, UINT_PTR id, DWORD now);
242
241
  void emitScrollEvent(Entry &entry, const char *which);
243
242
  void writeStateOffset(const Entry &entry);
244
243
 
@@ -1,4 +1,25 @@
1
- // The Win32 half of a `<Canvas>`: a raster surface drawn in the paint walk.
1
+ // The Win32 half of a `<Canvas>`: a surface drawn in the paint walk.
2
+ //
3
+ // ## Two implementations, one pair of functions
4
+ //
5
+ // Both generations of react-native-skia answer to the two functions at the
6
+ // bottom of this file, and they are never both linked: the 2.x one is
7
+ // `Win32SkiaPeer.cpp` beside this header, compiled when core's cmake/Skia.cmake
8
+ // found a 2.x install, and the 3.x one is basalt-skia's
9
+ // `win32/BasaltSkiaCanvasWin32.cpp`, compiled when it found a 3.x one.
10
+ // cmake/Skia.cmake refuses a 3.x install by version, so exactly one definition
11
+ // of each exists. Win32MountingManager.cpp calls them under a guard that
12
+ // covers either, the way the AppKit one does -- see AppKitSkiaPeer.h, which is
13
+ // this arrangement on the other host.
14
+ //
15
+ // They differ in the component they answer to, which is 3.x's rename: a 2.x
16
+ // `<Canvas>` mounts a `SkiaPictureView` and a 3.x one a `SkiaView`. Both
17
+ // descriptors are registered on all three hosts.
18
+ //
19
+ // What follows describes the 2.x one. The 3.x one reaches the same place by a
20
+ // different route -- it renders on the GPU through Dawn and D3D12 and reads
21
+ // the frame back, rather than rasterising on the CPU -- and for the same
22
+ // reason, which is the next paragraph. BasaltSkiaCanvasWin32.h has its half.
2
23
  //
3
24
  // The other two hosts give the package a layer of its own -- a CAMetalLayer on
4
25
  // macOS, a GL surface on GTK -- and let it present straight to the compositor.
@@ -39,9 +60,10 @@ namespace win32 {
39
60
  class RnWin32View;
40
61
  }
41
62
 
42
- // Gives `view` a Skia canvas if `shadowView` is a SkiaPictureView, updating its
43
- // size if it already has one. False when the component is something else, so
44
- // the caller can treat this as a filter rather than asking twice.
63
+ // Gives `view` a Skia canvas if `shadowView` is a canvas view -- a
64
+ // SkiaPictureView on 2.x, a SkiaView on 3.x -- updating its size if it
65
+ // already has one. False when the component is something else, so the caller
66
+ // can treat this as a filter rather than asking twice.
45
67
  //
46
68
  // `requestRepaint` is how a picture pushed from JavaScript reaches the screen.
47
69
  // Windows repaints on demand, so rendering a frame into the surface changes