diff --git a/.github/README.md b/.github/README.md
index caf6261..5453a32 100644
--- a/.github/README.md
+++ b/.github/README.md
@@ -151,7 +151,8 @@ Backend options:
| --- | --- |
| `ImGui_ImplRoblox.Init(parent, render_scale)` | `render_scale` = your OS display scale renders at physical-pixel resolution (crisper, same size as desktop imgui). |
| `ImGui_ImplRoblox.SetRenderScale(scale)` | Change it at runtime. |
-| `ImGui_ImplRoblox.SetRenderRate(hz)` | Max tile redraws per second (default 60, `0` = every frame). Your code still runs every frame. |
+| `ImGui_ImplRoblox.SetRenderRate(hz)` | Max tile redraws per second (default `0` = every frame). Your code still runs every frame. |
+| `ImGui_ImplRoblox.SetRedrawBudget(ms)` | Max milliseconds per frame spent redrawing changed tiles (default 1.5, `0` = no limit). Tiles over the budget keep their old pixels for a few frames, so big changes are spread over frames instead of one long frame. |
| `ImGui_ImplRoblox.SetBusyRenderRate(hz)` | Optional lower redraw rate while scrolling, resizing or moving windows (default off). |
| `ImGui_ImplRoblox.SetProfiling(true)` | MicroProfiler (Ctrl+F6) labels for every backend stage. |
| `ImGui_ImplRoblox.AddFile(path, bytes)` | Register a file (e.g. a `.ttf`) so `io.Fonts:AddFontFromFileTTF(path, size)` can load it. |
@@ -178,7 +179,7 @@ The cost is CPU time in Luau, so the backend is built around **not drawing**:
3. **Scrolling reuses pixels.** Window content is a separate layer inside a `ClipsDescendants` frame sized to the window's visible area, positioned at the scroll offset. Scrolling moves it like dragging moves a window; only rows entering view are drawn. Decorations (title bar, borders, scrollbar) stay in their own layers.
4. **Skipping unchanged windows.** Vertices are stored in a flat Luau `buffer` (36 bytes each) instead of tables. Before any per-tile work, a window's raw vertex/index bytes are compared with last frame's; if identical, its layers are just restacked.
5. **Fast rasterizer.** `@native` functions; axis-aligned quads (most rects and every glyph) take a span-fill path, flat rows are copied instead of re-blended, gradient rects (color pickers) are filled per row, and only real triangles (anti-aliased edges, curves) use the generic edge-function rasterizer.
-6. **Render rate.** Tiles update at most `SetRenderRate(hz)` times per second (default 60), optionally lower while scrolling/resizing (`SetBusyRenderRate`). Your UI code and Dear ImGui's logic still run every frame, so input is never dropped.
+6. **Redraw budget.** Changed tiles are redrawn every frame within `SetRedrawBudget(ms)` (default 1.5 ms); the rest keep their old pixels and follow on the next frames. Optionally capped with `SetRenderRate(hz)` / `SetBusyRenderRate(hz)`. Your UI code and Dear ImGui's logic still run every frame, so input is never dropped.
**Input.** Mouse and keys come from `UserInputService`. While the mouse is over Dear ImGui (`io.WantCaptureMouse`), the tiles are `Active`, so clicks and the wheel don't reach the game (camera zoom). Typing comes from key codes by default (printable ASCII). Call `ImGui_ImplRoblox.SetTextProxy(true)` to opt in to a hidden `TextBox` that holds focus while a text field is active: the OS then delivers typing (any layout), paste and copy/cut through it, and its text mirrors Dear ImGui's current selection. Fonts are TTFs rasterized by the ported stb_truetype into the font atlas, which becomes a backend texture like any image (`CreateTexture` works for your own pixels too).
diff --git a/README.md b/README.md
index 31a5c58..202f74b 100644
--- a/README.md
+++ b/README.md
@@ -148,7 +148,8 @@ Backend options:
| --- | --- |
| `ImGui_ImplRoblox.Init(parent, render_scale)` | `render_scale` = your OS display scale renders at physical-pixel resolution (crisper, same size as desktop imgui). |
| `ImGui_ImplRoblox.SetRenderScale(scale)` | Change it at runtime. |
-| `ImGui_ImplRoblox.SetRenderRate(hz)` | Max tile redraws per second (default 60, `0` = every frame). Your code still runs every frame. |
+| `ImGui_ImplRoblox.SetRenderRate(hz)` | Max tile redraws per second (default `0` = every frame). Your code still runs every frame. |
+| `ImGui_ImplRoblox.SetRedrawBudget(ms)` | Max milliseconds per frame spent redrawing changed tiles (default 1.5, `0` = no limit). Tiles over the budget keep their old pixels for a few frames, so big changes are spread over frames instead of one long frame. |
| `ImGui_ImplRoblox.SetBusyRenderRate(hz)` | Optional lower redraw rate while scrolling, resizing or moving windows (default off). |
| `ImGui_ImplRoblox.SetProfiling(true)` | MicroProfiler (Ctrl+F6) labels for every backend stage. |
| `ImGui_ImplRoblox.AddFile(path, bytes)` | Register a file (e.g. a `.ttf`) so `io.Fonts:AddFontFromFileTTF(path, size)` can load it. |
@@ -175,7 +176,7 @@ The cost is CPU time in Luau, so the backend is built around **not drawing**:
3. **Scrolling reuses pixels.** Window content is a separate layer inside a `ClipsDescendants` frame sized to the window's visible area, positioned at the scroll offset. Scrolling moves it like dragging moves a window; only rows entering view are drawn. Decorations (title bar, borders, scrollbar) stay in their own layers.
4. **Skipping unchanged windows.** Vertices are stored in a flat Luau `buffer` (36 bytes each) instead of tables. Before any per-tile work, a window's raw vertex/index bytes are compared with last frame's; if identical, its layers are just restacked.
5. **Fast rasterizer.** `@native` functions; axis-aligned quads (most rects and every glyph) take a span-fill path, flat rows are copied instead of re-blended, gradient rects (color pickers) are filled per row, and only real triangles (anti-aliased edges, curves) use the generic edge-function rasterizer.
-6. **Render rate.** Tiles update at most `SetRenderRate(hz)` times per second (default 60), optionally lower while scrolling/resizing (`SetBusyRenderRate`). Your UI code and Dear ImGui's logic still run every frame, so input is never dropped.
+6. **Redraw budget.** Changed tiles are redrawn every frame within `SetRedrawBudget(ms)` (default 1.5 ms); the rest keep their old pixels and follow on the next frames. Optionally capped with `SetRenderRate(hz)` / `SetBusyRenderRate(hz)`. Your UI code and Dear ImGui's logic still run every frame, so input is never dropped.
**Input.** Mouse and keys come from `UserInputService`. While the mouse is over Dear ImGui (`io.WantCaptureMouse`), the tiles are `Active`, so clicks and the wheel don't reach the game (camera zoom). Typing comes from key codes by default (printable ASCII). Call `ImGui_ImplRoblox.SetTextProxy(true)` to opt in to a hidden `TextBox` that holds focus while a text field is active: the OS then delivers typing (any layout), paste and copy/cut through it, and its text mirrors Dear ImGui's current selection. Fonts are TTFs rasterized by the ported stb_truetype into the font atlas, which becomes a backend texture like any image (`CreateTexture` works for your own pixels too).
diff --git a/backends/imgui_impl_roblox.lua b/backends/imgui_impl_roblox.lua
index 779443e..90f61fd 100644
--- a/backends/imgui_impl_roblox.lua
+++ b/backends/imgui_impl_roblox.lua
@@ -142,6 +142,7 @@ end
---------------------------------------------------------
-- Copies a region of ImTextureData pixels (1-based byte table) into our RGBA8 buffer
+@native
local function CopyTexturePixels(tex, dst, x, y, w, h)
local bpp, tex_w = tex.BytesPerPixel, tex.Width
for row = 0, h - 1 do
@@ -208,6 +209,8 @@ end
-- Blending is non-premultiplied "over" with a transparent (alpha 0) background, so the game stays visible behind ImGui.
local buf, buf_w -- buffer/width of the tile being drawn
+local rect_cols = table.create(256, 0) -- scratch: texel column offsets for DrawRect
+local sub_bufs, n_sub_bufs = {}, 0 -- scratch: partial tile upload buffers by size
local TILE_SIZE -- Vector2, set below
-- Blends one pixel (r, g, b, a are 0..255). One u32 read/write, one division in the general case.
@@ -337,18 +340,30 @@ local function DrawRect(x0, y0, x1, y1, u0, v0, u1, v1, col, t, cx0, cy0, cx1, c
local du, dv = (u1 - u0) / (x1 - x0), (v1 - v0) / (y1 - y0)
local tw, th, tbuf = t.Width, t.Height, t.Buffer
local r255, g255, b255, a255 = r / 255, g / 255, b / 255, a / 255
+ -- The texel column only depends on x: compute it once per column instead of once per pixel
+ local ncol = px1 - px0
+ local cols = rect_cols
+ for c = 1, ncol do
+ local tx = floor((u0 + (px0 + c - 1 + 0.5 - x0) * du) * tw)
+ if tx < 0 then tx = 0 elseif tx >= tw then tx = tw - 1 end
+ cols[c] = tx * 4
+ end
for y = py0, py1 - 1 do
local ty = floor((v0 + (y + 0.5 - y0) * dv) * th)
if ty < 0 then ty = 0 elseif ty >= th then ty = th - 1 end
- local trow = ty * tw
+ local trow4 = ty * tw * 4
local o = (y * buf_w + px0) * 4
- for x = px0, px1 - 1 do
- local tx = floor((u0 + (x + 0.5 - x0) * du) * tw)
- if tx < 0 then tx = 0 elseif tx >= tw then tx = tw - 1 end
- local to = (trow + tx) * 4
+ for c = 1, ncol do
+ local to = trow4 + cols[c]
local ta = readu8(tbuf, to + 3)
if ta > 0 then
- BlendPixel(o, readu8(tbuf, to) * r255, readu8(tbuf, to + 1) * g255, readu8(tbuf, to + 2) * b255, ta * a255)
+ local sa = ta * a255
+ local sr, sg, sb = readu8(tbuf, to) * r255, readu8(tbuf, to + 1) * g255, readu8(tbuf, to + 2) * b255
+ if sa >= 255 then -- (BlendPixel's opaque case, inlined: most glyph texels)
+ writeu32(buf, o, floor(sr + 0.5) + floor(sg + 0.5) * 256 + floor(sb + 0.5) * 65536 + 4278190080)
+ else
+ BlendPixel(o, sr, sg, sb, sa)
+ end
end
o = o + 4
end
@@ -442,6 +457,7 @@ local P1, P2 = 2147483647, 2147483629 -- two independent 31 bit hashes per tile
local KEY_W = 64 -- tile key = ty * KEY_W + tx (layers up to 16384 px wide)
local CLIP_PAD = 2048 -- content up to this far off screen is kept, so dragging a window past the edge doesn't redraw it
local LAYER_KEEP_FRAMES = 120 -- unused layers are destroyed after this many rendered frames
+local MAX_DEFER_FRAMES = 8 -- a tile is redrawn at the latest this many frames after it changed, even over the redraw budget
-- Primitive arrays (reused between frames, no per-frame allocation)
local pr_a, pr_b, pr_c, pr_d, pr_t, pr_g = {}, {}, {}, {}, {}, {} -- vertices (pr_d == false -> triangle), texture, gradient kind
@@ -451,6 +467,7 @@ local pr_tex = {} -- texture id
local pr_vb = {} -- vertex buffer (draw list VtxBuffer.Buf)
local pr_k = {} -- layer kind (1..3)
local plist1, plist2, plist3 = {}, {}, {} -- primitive indices per layer kind
+local cm_free, cm_kind, cm_x0, cm_y0, cm_x1, cm_y1 = {}, {}, {}, {}, {}, {} -- per draw command (collect -> Snapshot)
@native
local function HashVertex(h1, h2, vbuf, v, ox, oy)
@@ -540,7 +557,7 @@ local function GetLayer(bd, draw_list, kind)
else
frame = NewFrame(bd.Gui, false); root = frame
end
- layer = { Root = root, Frame = frame, Tiles = {}, TH1 = {}, TH2 = {}, TN = {}, TL = {}, TX = {}, TY = {}, Touched = {}, NTouched = 0,
+ layer = { Root = root, Frame = frame, Tiles = {}, TH1 = {}, TH2 = {}, TN = {}, TL = {}, TL1 = {}, TL2 = {}, TLF = {}, TX = {}, TY = {}, Touched = {}, NTouched = 0,
Visible = false, X = false, Y = false, Z = -1, CX0 = false, CY0 = false, CX1 = false, CY1 = false }
set[kind] = layer
return layer
@@ -567,7 +584,8 @@ local function GetTile(bd, layer, key, tx, ty)
label.Visible = false
label.Active = bd.TilesActive
label.Parent = layer.Frame
- tile = { Image = img, Label = label, Buffer = buffer.create(TILE * TILE * 4), H1 = -1, H2 = -1, Visible = false, BX0 = TILE, BY0 = TILE, BX1 = 0, BY1 = 0 }
+ tile = { Image = img, Label = label, Buffer = buffer.create(TILE * TILE * 4), H1 = -1, H2 = -1, Visible = false, BX0 = TILE, BY0 = TILE, BX1 = 0, BY1 = 0,
+ ON = -1, OH1 = {}, OH2 = {}, OF = {}, OB0 = {}, OB1 = {}, OB2 = {}, OB3 = {} } -- last drawn primitive list (hashes, bounds in tile)
layer.Tiles[key] = tile
return tile
end
@@ -587,31 +605,129 @@ local function DestroyTiles(bd)
bd.Layers = {}
end
--- Exact signature of everything in a draw list that affects its layers (nil = can't skip: user callbacks)
-local sig_buf = buffer.create(4096)
+-- Snapshot of everything in a draw list that affects its layers, compared exactly with the next frame's list:
+-- 1 = identical (only restack), 2 = everything moved by the same whole pixels (dx, dy) (only move the layers: a window
+-- being dragged), 0 = changed (full path). Compared in place (no strings built): early exit at the first difference.
+local writef64 = buffer.writef64
+local CMD_STRIDE = 70 -- clip (4 f64), ElemCount, IdxOffset, VtxOffset (u32), tex id (f64), kind (u8), free (u8), bounds of its primitives (4 x i32)
+
@native
-local function DrawListSignature(draw_list, ox, oy)
+local function Snapshot(set, draw_list, ox, oy, sw, sh)
+ local nv = draw_list.VtxBuffer.Size * VTX_STRIDE
local idx, ni = draw_list.IdxBuffer.Data, draw_list.IdxBuffer.Size
local ncmd = draw_list.CmdBuffer.Size
- local need = ni * 4 + ncmd * 64 + 64
- if buffer.len(sig_buf) < need then sig_buf = buffer.create(need * 2) end
- local b, o = sig_buf, 0
- for i = 1, ni do writeu32(b, o, idx[i]); o += 4 end
+ local sv, si, sc = set.SV, set.SI, set.SC
+ if not sv or buffer.len(sv) < nv then sv = buffer.create(max(nv, 64) * 2); set.SV = sv end
+ if not si or buffer.len(si) < ni * 4 then si = buffer.create(max(ni, 16) * 8); set.SI = si end
+ if not sc or buffer.len(sc) < ncmd * CMD_STRIDE then sc = buffer.create(max(ncmd, 4) * CMD_STRIDE * 2); set.SC = sc end
+ buffer.copy(sv, 0, draw_list.VtxBuffer.Buf, 0, nv)
+ for i = 1, ni do writeu32(si, (i - 1) * 4, idx[i]) end
+ local o, ci = 0, 0
for _, pcmd in draw_list.CmdBuffer:iter() do
- if pcmd.UserCallback ~= nil then return nil end
+ if pcmd.UserCallback ~= nil then set.SnapOK = false; return end
local c = pcmd.ClipRect
- buffer.writef64(b, o, c.x); buffer.writef64(b, o + 8, c.y); buffer.writef64(b, o + 16, c.z); buffer.writef64(b, o + 24, c.w)
- writeu32(b, o + 32, pcmd.ElemCount); writeu32(b, o + 36, pcmd.IdxOffset); writeu32(b, o + 40, pcmd.VtxOffset)
- buffer.writef64(b, o + 44, pcmd:GetTexID())
- o += 52
+ writef64(sc, o, c.x); writef64(sc, o + 8, c.y); writef64(sc, o + 16, c.z); writef64(sc, o + 24, c.w)
+ writeu32(sc, o + 32, pcmd.ElemCount); writeu32(sc, o + 36, pcmd.IdxOffset); writeu32(sc, o + 40, pcmd.VtxOffset)
+ writef64(sc, o + 44, pcmd:GetTexID())
+ ci += 1
+ buffer.writeu8(sc, o + 52, cm_kind[ci] or 0); buffer.writeu8(sc, o + 53, cm_free[ci] and 1 or 0)
+ if cm_free[ci] then
+ buffer.writei32(sc, o + 54, cm_x0[ci]); buffer.writei32(sc, o + 58, cm_y0[ci]); buffer.writei32(sc, o + 62, cm_x1[ci]); buffer.writei32(sc, o + 66, cm_y1[ci])
+ end
+ o += CMD_STRIDE
+ end
+ set.SVN, set.SIN, set.SCN = nv, ni, ncmd
+ set.SOX, set.SOY, set.SSW, set.SSH = ox, oy, sw, sh
+ set.SCC = draw_list._ContentClipX0 ~= nil
+ set.SCX0, set.SCY0, set.SCX1, set.SCY1 = draw_list._ContentClipX0 or 0, draw_list._ContentClipY0 or 0, draw_list._ContentClipX1 or 0, draw_list._ContentClipY1 or 0
+ set.SSX, set.SSY = draw_list._ScrollOriginX or 0, draw_list._ScrollOriginY or 0
+ set.SnapOK = true
+end
+
+-- a + d == b exactly, with no rounding in a + d (so every later "x - origin" gives the same result as before the move)
+@native
+local function ShiftedExact(a, d, b)
+ local s = a + d
+ if s ~= b then return false end
+ local bb = s - a
+ return (a - (s - bb)) + (d - bb) == 0
+end
+
+@native
+local function CompareDrawList(set, draw_list, ox, oy, sw, sh)
+ if not set or not set.SnapOK then return 0 end
+ local nv = draw_list.VtxBuffer.Size * VTX_STRIDE
+ local ni, ncmd = draw_list.IdxBuffer.Size, draw_list.CmdBuffer.Size
+ if nv ~= set.SVN or ni ~= set.SIN or ncmd ~= set.SCN or ox ~= set.SOX or oy ~= set.SOY or sw ~= set.SSW or sh ~= set.SSH then return 0 end
+ if (draw_list._ContentClipX0 ~= nil) ~= set.SCC then return 0 end
+ local vb, sv = draw_list.VtxBuffer.Buf, set.SV
+ if nv == 0 then return 1 end
+ -- translation from the first vertex: whole pixels only, and only with DisplayPos at 0 (so it stays exact)
+ local dx, dy = readf64(vb, 0) - readf64(sv, 0), readf64(vb, 8) - readf64(sv, 8)
+ if dx ~= dx or dy ~= dy then return 0 end
+ if dx ~= 0 or dy ~= 0 then
+ if dx ~= floor(dx) or dy ~= floor(dy) or ox ~= 0 or oy ~= 0 or not set.SLimFree then return 0 end
+ -- everything must stay as far from the clip limits as before (nothing newly clamped)
+ if set.SEX0 + dx < -CLIP_PAD or set.SEY0 + dy < -CLIP_PAD or set.SEX1 + dx > sw + CLIP_PAD or set.SEY1 + dy > sh + CLIP_PAD then return 0 end
+ end
+ local idx, si = draw_list.IdxBuffer.Data, set.SI
+ for i = 1, ni do if idx[i] ~= readu32(si, (i - 1) * 4) then return 0 end end
+ local sc, o = set.SC, 0
+ local ccx0, ccy0, ccx1, ccy1 = draw_list._ContentClipX0, draw_list._ContentClipY0, draw_list._ContentClipX1, draw_list._ContentClipY1
+ local seen_content = false
+ for _, pcmd in draw_list.CmdBuffer:iter() do
+ if pcmd.UserCallback ~= nil then return 0 end
+ if pcmd.ElemCount ~= readu32(sc, o + 32) or pcmd.IdxOffset ~= readu32(sc, o + 36) or pcmd.VtxOffset ~= readu32(sc, o + 40) or pcmd:GetTexID() ~= readf64(sc, o + 44) then return 0 end
+ local c = pcmd.ClipRect
+ if dx == 0 and dy == 0 then
+ if c.x ~= readf64(sc, o) or c.y ~= readf64(sc, o + 8) or c.z ~= readf64(sc, o + 16) or c.w ~= readf64(sc, o + 24) then return 0 end
+ elseif not (ShiftedExact(readf64(sc, o), dx, c.x) and ShiftedExact(readf64(sc, o + 8), dy, c.y)
+ and ShiftedExact(readf64(sc, o + 16), dx, c.z) and ShiftedExact(readf64(sc, o + 24), dy, c.w)) then
+ -- An unchanged clip rect (e.g. the viewport) is fine if it clipped none of this command's primitives before
+ -- the move and still clips none after it
+ if c.x ~= readf64(sc, o) or c.y ~= readf64(sc, o + 8) or c.z ~= readf64(sc, o + 16) or c.w ~= readf64(sc, o + 24)
+ or buffer.readu8(sc, o + 53) == 0 then return 0 end
+ if buffer.readi32(sc, o + 54) + dx < floor(c.x) or buffer.readi32(sc, o + 58) + dy < floor(c.y)
+ or buffer.readi32(sc, o + 62) + dx > floor(c.z) or buffer.readi32(sc, o + 66) + dy > floor(c.w) then return 0 end
+ end
+ -- same layer kind as before (same rule as the collect pass)
+ if pcmd.ElemCount > 0 then
+ local kind = 1
+ if ccx0 and c.x >= ccx0 and c.y >= ccy0 and c.z <= ccx1 and c.w <= ccy1 then kind = 2; seen_content = true
+ elseif seen_content then kind = 3 end
+ if kind ~= buffer.readu8(sc, o + 52) then return 0 end
+ end
+ o += CMD_STRIDE
+ end
+ if dx == 0 and dy == 0 then
+ if set.SCC and (draw_list._ContentClipX0 ~= set.SCX0 or draw_list._ContentClipY0 ~= set.SCY0 or draw_list._ContentClipX1 ~= set.SCX1
+ or draw_list._ContentClipY1 ~= set.SCY1 or draw_list._ScrollOriginX ~= set.SSX or draw_list._ScrollOriginY ~= set.SSY) then return 0 end
+ for v = 0, nv - VTX_STRIDE, VTX_STRIDE do
+ if readf64(vb, v) ~= readf64(sv, v) or readf64(vb, v + 8) ~= readf64(sv, v + 8) or readf64(vb, v + 16) ~= readf64(sv, v + 16)
+ or readf64(vb, v + 24) ~= readf64(sv, v + 24) or readu32(vb, v + 32) ~= readu32(sv, v + 32) then return 0 end
+ end
+ return 1
+ end
+ if set.SCC and not (ShiftedExact(set.SCX0, dx, draw_list._ContentClipX0) and ShiftedExact(set.SCY0, dy, draw_list._ContentClipY0)
+ and ShiftedExact(set.SCX1, dx, draw_list._ContentClipX1) and ShiftedExact(set.SCY1, dy, draw_list._ContentClipY1)
+ and ShiftedExact(set.SSX, dx, draw_list._ScrollOriginX) and ShiftedExact(set.SSY, dy, draw_list._ScrollOriginY)) then return 0 end
+ for v = 0, nv - VTX_STRIDE, VTX_STRIDE do
+ if not ShiftedExact(readf64(sv, v), dx, readf64(vb, v)) or not ShiftedExact(readf64(sv, v + 8), dy, readf64(vb, v + 8))
+ or readf64(vb, v + 16) ~= readf64(sv, v + 16) or readf64(vb, v + 24) ~= readf64(sv, v + 24) or readu32(vb, v + 32) ~= readu32(sv, v + 32) then return 0 end
+ end
+ return 2, dx, dy
+end
+
+-- A window moved by whole pixels: its tiles are unchanged, only the layer frames move
+local function ShiftLayer(layer, dx, dy, scale)
+ layer.X += dx; layer.Y += dy
+ local root = layer.Root
+ if root ~= layer.Frame then
+ layer.CX0 += dx; layer.CY0 += dy; layer.CX1 += dx; layer.CY1 += dy
+ root.Position = UDim2.fromOffset(layer.CX0 / scale, layer.CY0 / scale)
+ else
+ root.Position = UDim2.fromOffset(layer.X / scale, layer.Y / scale)
end
- buffer.writef64(b, o, ox); buffer.writef64(b, o + 8, oy)
- buffer.writef64(b, o + 16, draw_list._ContentClipX0 or -1); buffer.writef64(b, o + 24, draw_list._ContentClipY0 or -1)
- buffer.writef64(b, o + 32, draw_list._ContentClipX1 or -1); buffer.writef64(b, o + 40, draw_list._ContentClipY1 or -1)
- buffer.writef64(b, o + 48, draw_list._ContentClipX0 and draw_list._ScrollOriginX or -1)
- buffer.writef64(b, o + 56, draw_list._ContentClipX0 and draw_list._ScrollOriginY or -1)
- o += 64
- return readstring(draw_list.VtxBuffer.Buf, 0, draw_list.VtxBuffer.Size * VTX_STRIDE) .. readstring(b, 0, o)
end
-- Overlays: user Instances (e.g. a Frame holding a ViewportFrame) placed inside a window, registered every frame with
@@ -690,9 +806,11 @@ local function PE() if profiling then profileend() end end
@native
local function DrawLayer(bd, layer, plist, np, lx0, ly0, ox, oy, force, z, clip_x0, clip_y0, clip_x1, clip_y1)
local scale = bd.Scale
+ local deferred = false
-- Pass 2: hash primitives into layer-relative tiles
PB("ImGui tile hash")
local th1, th2, tn, tl, touched = layer.TH1, layer.TH2, layer.TN, layer.TL, layer.Touched
+ local tl1, tl2, tlf = layer.TL1, layer.TL2, layer.TLF -- per-tile list of the primitives' hashes (for partial redraws)
for k = 1, layer.NTouched do tn[touched[k]] = 0 end
local nt = 0
for pi = 1, np do
@@ -701,23 +819,55 @@ local function DrawLayer(bd, layer, plist, np, lx0, ly0, ox, oy, force, z, clip_
-- The clip rect only affects pixels inside the primitive's bounds, so hash the clipped bounds (relative)
-- instead of the clip itself: a window clipped by the (fixed) viewport rect stays hash-stable when moved.
local cx0, cy0, cx1, cy1 = pr_bx0[p] - lx0, pr_by0[p] - ly0, pr_bx1[p] - lx0, pr_by1[p] - ly0
- local h1 = ((cx0 * 8191 + cy0) * 8191 + cx1) % P1
- local h2 = ((cy1 * 8191 + cx1) * 8191 + cy0 + tex_id) % P2
- local vax, vay = lx0 + ox, ly0 + oy
local vbuf = pr_vb[p]
- h1, h2 = HashVertex(h1, h2, vbuf, pr_a[p], vax, vay)
- h1, h2 = HashVertex(h1, h2, vbuf, pr_b[p], vax, vay)
- h1, h2 = HashVertex(h1, h2, vbuf, pr_c[p], vax, vay)
- local vd = pr_d[p]
- if vd then h1, h2 = HashVertex(h1, h2, vbuf, vd, vax, vay) end
+ local va, vd = pr_a[p], pr_d[p]
+ -- Flat rect (single colour, no gradient, constant uv): its pixels in a tile are exactly its clipped pixel rect
+ -- inside that tile filled with one colour, so hash that per tile instead of the vertices. Resizing a window
+ -- then only changes the tiles its edges cross, not every tile its background covers.
+ local flat_rect, rcol, ruv = false, 0, 0
+ if vd and not pr_g[p] then
+ local u0, v0 = readf64(vbuf, va + 16), readf64(vbuf, va + 24)
+ local vc = pr_c[p]
+ if not pr_t[p] or (u0 == readf64(vbuf, vc + 16) and v0 == readf64(vbuf, vc + 24)) then
+ flat_rect, rcol, ruv = true, readu32(vbuf, va + 32), u0 * 8192 + v0
+ end
+ end
+ local h1, h2 = 0, 0
+ local fsig = -1 -- flat rect: colour/uv/texture without the geometry (-1: not a flat rect)
+ if flat_rect then fsig = ((rcol * 8191 + ruv * 4096) % P1 * 64 + tex_id) % P1 end
+ if not flat_rect then
+ h1 = ((cx0 * 8191 + cy0) * 8191 + cx1) % P1
+ h2 = ((cy1 * 8191 + cx1) * 8191 + cy0 + tex_id) % P2
+ local vax, vay = lx0 + ox, ly0 + oy
+ if vd then
+ -- Axis aligned quad: the collect pass checked that TR/BL (positions, uvs, colours) follow from TL/BR
+ -- (and the gradient kind), so TL + BR + kind determine its pixels
+ h1, h2 = HashVertex(h1, h2, vbuf, va, vax, vay)
+ h1, h2 = HashVertex(h1, h2, vbuf, pr_c[p], vax, vay)
+ local gk = pr_g[p]
+ if gk then h1 = (h1 * 48271 + gk) % P1; h2 = (h2 * 16807 + gk * 3) % P2 end
+ else
+ h1, h2 = HashVertex(h1, h2, vbuf, va, vax, vay)
+ h1, h2 = HashVertex(h1, h2, vbuf, pr_b[p], vax, vay)
+ h1, h2 = HashVertex(h1, h2, vbuf, pr_c[p], vax, vay)
+ end
+ end
for ty = floor((pr_by0[p] - ly0) / TILE), floor((pr_by1[p] - 1 - ly0) / TILE) do
for tx = floor((pr_bx0[p] - lx0) / TILE), floor((pr_bx1[p] - 1 - lx0) / TILE) do
+ if flat_rect then
+ local tx0, ty0 = tx * TILE, ty * TILE
+ local ix0, iy0 = max(cx0 - tx0, 0), max(cy0 - ty0, 0)
+ local ix1, iy1 = min(cx1 - tx0, TILE), min(cy1 - ty0, TILE)
+ local r = ((ix0 * 131 + iy0) * 131 + ix1) * 131 + iy1
+ h1 = ((r * 48271 + rcol) % P1 * 48271 + ruv * 4096 + 7) % P1
+ h2 = ((r * 16807 + rcol + tex_id) % P2 * 16807 + ruv * 65521 + 11) % P2
+ end
local key = ty * KEY_W + tx
local k = tn[key]
if not k or k == 0 then
k = 0
th1[key], th2[key] = 0, 0
- if not tl[key] then tl[key] = {} end
+ if not tl[key] then tl[key] = {}; tl1[key] = {}; tl2[key] = {}; tlf[key] = {} end
nt = nt + 1
touched[nt] = key
layer.TX[key], layer.TY[key] = tx, ty
@@ -725,6 +875,7 @@ local function DrawLayer(bd, layer, plist, np, lx0, ly0, ox, oy, force, z, clip_
k = k + 1
tn[key] = k
tl[key][k] = p
+ tl1[key][k], tl2[key][k], tlf[key][k] = h1, h2, fsig
th1[key] = (th1[key] * 48271 + h1) % P1
th2[key] = (th2[key] * 16807 + h2 + k) % P2
end
@@ -758,7 +909,7 @@ local function DrawLayer(bd, layer, plist, np, lx0, ly0, ox, oy, force, z, clip_
PB("ImGui raster+upload")
for key, tile in pairs(layer.Tiles) do
if not tn[key] or tn[key] == 0 then
- if tile.Visible then tile.Visible = false; tile.Label.Visible = false; tile.H1 = -1 end
+ if tile.Visible then tile.Visible = false; tile.Label.Visible = false; tile.H1 = -1; tile.ON = -1 end
end
end
for kk = 1, nt do
@@ -766,14 +917,27 @@ local function DrawLayer(bd, layer, plist, np, lx0, ly0, ox, oy, force, z, clip_
local count = tn[key]
local tx, ty = layer.TX[key], layer.TY[key]
local tile = GetTile(bd, layer, key, tx, ty)
- if force or tile.H1 ~= th1[key] or tile.H2 ~= th2[key] then
+ local dirty = force or tile.H1 ~= th1[key] or tile.H2 ~= th2[key]
+ if dirty and bd.RedrawStarted and (tile.Deferred or 0) < MAX_DEFER_FRAMES
+ and ((bd.RedrawBudget > 0 and os.clock() - bd.FrameT0 >= bd.RedrawBudget) or (bd.TileLimit and bd.FrameTiles >= bd.TileLimit)) then -- (TileLimit: test hook, tools/test/scripts/t_pixels.luau)
+ -- Over this frame's redraw budget: keep showing the old pixels, redraw on a following frame. The tile's
+ -- stored hashes/list still describe what it shows, so the next comparison is against the real content.
+ tile.Deferred = (tile.Deferred or 0) + 1
+ if force then tile.H1 = -1; tile.ON = -1 end -- full redraw later (force doesn't persist)
+ deferred = true
+ dirty = false
+ end
+ if dirty then
+ bd.RedrawStarted = true
+ bd.FrameTiles += 1
+ tile.Deferred = 0
tile.H1, tile.H2 = th1[key], th2[key]
local x0, y0 = lx0 + tx * TILE, ly0 + ty * TILE
local x1, y1 = x0 + TILE, y0 + TILE
buf, buf_w = tile.Buffer, TILE
- local list = tl[key]
- -- Dirty region = old content bounds + new content bounds. Outside it the tile is already
- -- transparent, so only this region is cleared and uploaded (edge tiles are often mostly empty).
+ local list, lh1, lh2 = tl[key], tl1[key], tl2[key]
+ local oh1, oh2, ob0, ob1, ob2, ob3, on = tile.OH1, tile.OH2, tile.OB0, tile.OB1, tile.OB2, tile.OB3, tile.ON
+ -- New content bounds (union of the primitives' bounds in this tile)
local nx0, ny0, nx1, ny1 = TILE, TILE, 0, 0
for k = 1, count do
local p = list[k]
@@ -785,8 +949,63 @@ local function DrawLayer(bd, layer, plist, np, lx0, ly0, ox, oy, force, z, clip_
if d > ny1 then ny1 = d end
end
nx0, ny0, nx1, ny1 = max(nx0, 0), max(ny0, 0), min(nx1, TILE), min(ny1, TILE)
- local rx0, ry0, rx1, ry1 = min(nx0, tile.BX0), min(ny0, tile.BY0), max(nx1, tile.BX1), max(ny1, tile.BY1)
+ local rx0, ry0, rx1, ry1
+ if force or on < 0 then
+ -- Dirty region = old content bounds + new content bounds. Outside it the tile is already
+ -- transparent, so only this region is cleared and uploaded (edge tiles are often mostly empty).
+ rx0, ry0, rx1, ry1 = min(nx0, tile.BX0), min(ny0, tile.BY0), max(nx1, tile.BX1), max(ny1, tile.BY1)
+ else
+ -- Partial redraw: the primitives before the first and after the last changed one are the same as last
+ -- time. A pixel that no changed primitive (old or new) touches is covered by the same primitives in the
+ -- same order, so it is already correct: only the bounds of the changed ones are cleared and redrawn.
+ local i, lim = 1, min(on, count)
+ while i <= lim and oh1[i] == lh1[i] and oh2[i] == lh2[i] do i += 1 end
+ local j = 0
+ while j <= lim - i and oh1[on - j] == lh1[count - j] and oh2[on - j] == lh2[count - j] do j += 1 end
+ rx0, ry0, rx1, ry1 = TILE, TILE, 0, 0
+ local of, lf = tile.OF, tlf[key]
+ local paired = on - j - i == count - j - i
+ for k = i, count - j do
+ local p = list[k]
+ local a, b, c, d = max(pr_bx0[p] - x0, 0), max(pr_by0[p] - y0, 0), min(pr_bx1[p] - x0, TILE), min(pr_by1[p] - y0, TILE)
+ local oa, ob, oc, od = ob0[k], ob1[k], ob2[k], ob3[k]
+ if paired and lf[k] >= 0 and lf[k] == of[k] then
+ -- Same flat rect (colour/uv/texture) with new bounds: pixels inside both are unchanged, only the
+ -- strips between the old and new edges can differ (e.g. a window background growing on resize)
+ local uy0, uy1 = min(b, ob), max(d, od)
+ local ux0, ux1 = min(a, oa), max(c, oc)
+ if a ~= oa then ux0 = min(a, oa); local e = max(a, oa); rx0 = min(rx0, ux0); rx1 = max(rx1, e); ry0 = min(ry0, uy0); ry1 = max(ry1, uy1) end
+ if c ~= oc then local e = min(c, oc); rx0 = min(rx0, e); rx1 = max(rx1, max(c, oc)); ry0 = min(ry0, uy0); ry1 = max(ry1, uy1) end
+ if b ~= ob then ry0 = min(ry0, min(b, ob)); ry1 = max(ry1, max(b, ob)); rx0 = min(rx0, ux0); rx1 = max(rx1, ux1) end
+ if d ~= od then ry0 = min(ry0, min(d, od)); ry1 = max(ry1, max(d, od)); rx0 = min(rx0, ux0); rx1 = max(rx1, ux1) end
+ else
+ if a < rx0 then rx0 = a end
+ if b < ry0 then ry0 = b end
+ if c > rx1 then rx1 = c end
+ if d > ry1 then ry1 = d end
+ if k <= on - j then
+ if oa < rx0 then rx0 = oa end
+ if ob < ry0 then ry0 = ob end
+ if oc > rx1 then rx1 = oc end
+ if od > ry1 then ry1 = od end
+ end
+ end
+ end
+ for k = count - j + 1, on - j do -- old middle longer than the new one
+ if ob0[k] < rx0 then rx0 = ob0[k] end
+ if ob1[k] < ry0 then ry0 = ob1[k] end
+ if ob2[k] > rx1 then rx1 = ob2[k] end
+ if ob3[k] > ry1 then ry1 = ob3[k] end
+ end
+ end
tile.BX0, tile.BY0, tile.BX1, tile.BY1 = nx0, ny0, nx1, ny1
+ -- Remember this list for the next partial redraw
+ for k = 1, count do
+ local p = list[k]
+ oh1[k], oh2[k], tile.OF[k] = lh1[k], lh2[k], tlf[key][k]
+ ob0[k], ob1[k], ob2[k], ob3[k] = max(pr_bx0[p] - x0, 0), max(pr_by0[p] - y0, 0), min(pr_bx1[p] - x0, TILE), min(pr_by1[p] - y0, TILE)
+ end
+ tile.ON = count
local rw = rx1 - rx0
if rw == TILE then
buffer.fill(buf, ry0 * TILE * 4, 0, (ry1 - ry0) * TILE * 4)
@@ -796,8 +1015,10 @@ local function DrawLayer(bd, layer, plist, np, lx0, ly0, ox, oy, force, z, clip_
local tox, toy = ox + x0, oy + y0
for k = 1, count do
local p = list[k]
- local cx0, cy0 = max(pr_cx0[p], x0) - x0, max(pr_cy0[p], y0) - y0
- local cx1, cy1 = min(pr_cx1[p], x1) - x0, min(pr_cy1[p], y1) - y0
+ -- clip to the dirty region (only those pixels are redrawn)
+ local cx0, cy0 = max(pr_cx0[p] - x0, rx0), max(pr_cy0[p] - y0, ry0)
+ local cx1, cy1 = min(pr_cx1[p] - x0, rx1), min(pr_cy1[p] - y0, ry1)
+ if cx0 >= cx1 or cy0 >= cy1 or pr_bx0[p] - x0 >= rx1 or pr_by0[p] - y0 >= ry1 or pr_bx1[p] - x0 <= rx0 or pr_by1[p] - y0 <= ry0 then continue end
local va, vc, vd, vbuf = pr_a[p], pr_c[p], pr_d[p], pr_vb[p]
if vd then
local ax, ay, cx, cy = readf64(vbuf, va) - tox, readf64(vbuf, va + 8) - toy, readf64(vbuf, vc) - tox, readf64(vbuf, vc + 8) - toy
@@ -816,31 +1037,42 @@ local function DrawLayer(bd, layer, plist, np, lx0, ly0, ox, oy, force, z, clip_
tile.Image:WritePixelsBuffer(Vector2.zero, TILE_SIZE, buf)
elseif rw > 0 and ry1 > ry0 then
local rh = ry1 - ry0
- local sub = buffer.create(rw * rh * 4)
+ local sk = rw * 1024 + rh -- partial upload buffers are reused (WritePixelsBuffer needs the exact size)
+ local sub = sub_bufs[sk]
+ if not sub then
+ if n_sub_bufs >= 256 then table.clear(sub_bufs); n_sub_bufs = 0 end
+ sub = buffer.create(rw * rh * 4); sub_bufs[sk] = sub; n_sub_bufs += 1
+ end
for y = 0, rh - 1 do buffer.copy(sub, y * rw * 4, buf, ((ry0 + y) * TILE + rx0) * 4, rw * 4) end
tile.Image:WritePixelsBuffer(Vector2.new(rx0, ry0), Vector2.new(rw, rh), sub)
end
PE()
end
- if not tile.Visible then
+ if not tile.Visible and (tile.Deferred or 0) == 0 then -- (a deferred tile that was hidden holds old pixels: stays hidden until drawn)
tile.Visible = true
tile.Label.Visible = true
end
end
PE()
+ return deferred
end
@native
function ImGui_ImplRoblox_RenderDrawData(draw_data)
local bd = ImGui_ImplRoblox_GetBackendData()
PB("ImGui RenderDrawData")
+ bd.FrameT0 = os.clock()
+ bd.FrameTiles = 0
+ bd.RedrawStarted = false -- the first dirty tile of a frame is always redrawn (progress), the rest within the budget
+ bd.Stale = nil
PB("ImGui textures")
if draw_data.Textures ~= nil then
for _, tex in draw_data.Textures:iter() do
if tex.Status ~= ImTextureStatus.OK then
+ -- No redraw needed for WantUpdates: it only writes texture portions which have never been used before
+ -- (new glyphs/rects), so no existing tile samples them. Created textures get a new (never reused) id.
ImGui_ImplRoblox_UpdateTexture(tex)
- bd.ForceRedraw = true -- texture contents changed: tiles sampling it must be redrawn
end
end
end
@@ -878,20 +1110,27 @@ function ImGui_ImplRoblox_RenderDrawData(draw_data)
local n = 0
local z = 0
+ local any_deferred = false
for _, draw_list in draw_data.CmdLists:iter() do
- -- Unchanged draw list (same vertex bytes, indices, commands, origin and scroll): its layers are already
- -- correct, only restack them. Static windows then cost one byte compare instead of collect + hash.
+ -- Unchanged draw list (same vertices, indices, commands, origin and scroll): its layers are already correct,
+ -- only restack them. Moved by whole pixels (a dragged window): only move its layers. Either way no collect + hash.
PB("ImGui unchanged check")
- local sig = DrawListSignature(draw_list, ox, oy)
local set = bd.Layers[draw_list]
- if sig and set and not force and set.Sig == sig then
+ local same, sdx, sdy = 0, 0, 0
+ if not force then same, sdx, sdy = CompareDrawList(set, draw_list, ox, oy, sw, sh) end
+ if same ~= 0 then
for kind = 1, 3 do
local layer = set[kind]
if layer and layer.Visible then
+ if same == 2 then ShiftLayer(layer, sdx, sdy, bd.Scale) end
z += 1
if layer.Z ~= z then layer.Z = z; layer.Root.ZIndex = z end
end
end
+ if same == 2 then
+ Snapshot(set, draw_list, ox, oy, sw, sh)
+ set.SEX0 += sdx; set.SEY0 += sdy; set.SEX1 += sdx; set.SEY1 += sdy
+ end
set.LastUsed = frame_no
z = PlaceOverlays(bd, draw_list, z, ox, oy)
PE()
@@ -909,13 +1148,30 @@ function ImGui_ImplRoblox_RenderDrawData(draw_data)
content_x1, content_y1 = min(floor(ccx1 - ox), lim_x1), min(floor(ccy1 - oy), lim_y1)
end
local seen_content = false
+ -- extents of everything clamped by the clip limits below (lim_free: nothing was), for the move fast path
+ local ex0, ey0, ex1, ey1 = math.huge, math.huge, -math.huge, -math.huge
+ local lim_free = true
+ if ccx0 then
+ local a, b, c, d = floor(ccx0 - ox), floor(ccy0 - oy), floor(ccx1 - ox), floor(ccy1 - oy)
+ if a < lim_x0 or b < lim_y0 or c > lim_x1 or d > lim_y1 then lim_free = false end
+ ex0, ey0, ex1, ey1 = min(ex0, a), min(ey0, b), max(ex1, c), max(ey1, d)
+ end
+ local ci = 0
for _, pcmd in draw_list.CmdBuffer:iter() do
+ ci += 1
+ cm_free[ci], cm_kind[ci] = false, 0
if pcmd.UserCallback ~= nil then
pcmd.UserCallback(draw_list, pcmd)
elseif pcmd.ElemCount > 0 then
local clip = pcmd.ClipRect
- local cx0, cy0 = max(floor(clip.x - ox), lim_x0), max(floor(clip.y - oy), lim_y0)
- local cx1, cy1 = min(floor(clip.z - ox), lim_x1), min(floor(clip.w - oy), lim_y1)
+ local rcx0, rcy0, rcx1, rcy1 = floor(clip.x - ox), floor(clip.y - oy), floor(clip.z - ox), floor(clip.w - oy)
+ if rcx0 < lim_x0 or rcy0 < lim_y0 or rcx1 > lim_x1 or rcy1 > lim_y1 then lim_free = false end
+ if rcx0 < ex0 then ex0 = rcx0 end
+ if rcy0 < ey0 then ey0 = rcy0 end
+ if rcx1 > ex1 then ex1 = rcx1 end
+ if rcy1 > ey1 then ey1 = rcy1 end
+ local cx0, cy0 = max(rcx0, lim_x0), max(rcy0, lim_y0)
+ local cx1, cy1 = min(rcx1, lim_x1), min(rcy1, lim_y1)
local kind = 1
if ccx0 and clip.x >= ccx0 and clip.y >= ccy0 and clip.z <= ccx1 and clip.w <= ccy1 then
kind = 2; seen_content = true
@@ -926,6 +1182,8 @@ function ImGui_ImplRoblox_RenderDrawData(draw_data)
elseif seen_content then
kind = 3
end
+ cm_kind[ci] = kind
+ local cfree, cex0, cey0, cex1, cey1 = cx1 > cx0 and cy1 > cy0, math.huge, math.huge, -math.huge, -math.huge
if cx1 > cx0 and cy1 > cy0 then
local tex_id = pcmd:GetTexID()
local t = bd.Textures[tex_id]
@@ -971,6 +1229,16 @@ function ImGui_ImplRoblox_RenderDrawData(draw_data)
bx1, by1 = ceil(max(pax, pbx, pcx) - ox) + 1, ceil(max(pay, pby, pcy) - oy) + 1
i = i + 3
end
+ if bx0 < cx0 or by0 < cy0 or bx1 > cx1 or by1 > cy1 then cfree = false end
+ if bx0 < cex0 then cex0 = bx0 end
+ if by0 < cey0 then cey0 = by0 end
+ if bx1 > cex1 then cex1 = bx1 end
+ if by1 > cey1 then cey1 = by1 end
+ if bx0 < ex0 then ex0 = bx0 end
+ if by0 < ey0 then ey0 = by0 end
+ if bx1 > ex1 then ex1 = bx1 end
+ if by1 > ey1 then ey1 = by1 end
+ if bx0 < lim_x0 or by0 < lim_y0 or bx1 > lim_x1 or by1 > lim_y1 then lim_free = false end
if bx0 < cx0 then bx0 = cx0 end
if by0 < cy0 then by0 = cy0 end
if bx1 > cx1 then bx1 = cx1 end
@@ -984,12 +1252,16 @@ function ImGui_ImplRoblox_RenderDrawData(draw_data)
end
end
end
+ if cex0 > cex1 then cex0, cey0, cex1, cey1 = 0, 0, 0, 0 end -- (no primitives)
+ cm_free[ci] = cfree
+ cm_x0[ci], cm_y0[ci], cm_x1[ci], cm_y1[ci] = cex0, cey0, cex1, cey1
end
end
PE()
-- Split into layer kinds and place each one
set = bd.Layers[draw_list]
+ local list_deferred = false
local nk1, nk2, nk3 = 0, 0, 0
local o1x, o1y, o3x, o3y = math.huge, math.huge, math.huge, math.huge
for p = first, n do
@@ -1008,19 +1280,28 @@ function ImGui_ImplRoblox_RenderDrawData(draw_data)
end
if nk1 > 0 then
z += 1
- DrawLayer(bd, GetLayer(bd, draw_list, 1), plist1, nk1, o1x, o1y, ox, oy, force, z)
+ if DrawLayer(bd, GetLayer(bd, draw_list, 1), plist1, nk1, o1x, o1y, ox, oy, force, z) then list_deferred = true end
elseif set and set[1] then HideLayer(set[1]) end
if nk2 > 0 then
z += 1
- DrawLayer(bd, GetLayer(bd, draw_list, 2), plist2, nk2, floor(draw_list._ScrollOriginX - ox), floor(draw_list._ScrollOriginY - oy), ox, oy, force, z,
- content_x0, content_y0, content_x1, content_y1)
+ if DrawLayer(bd, GetLayer(bd, draw_list, 2), plist2, nk2, floor(draw_list._ScrollOriginX - ox), floor(draw_list._ScrollOriginY - oy), ox, oy, force, z,
+ content_x0, content_y0, content_x1, content_y1) then list_deferred = true end
elseif set and set[2] then HideLayer(set[2]) end
if nk3 > 0 then
z += 1
- DrawLayer(bd, GetLayer(bd, draw_list, 3), plist3, nk3, o3x, o3y, ox, oy, force, z)
+ if DrawLayer(bd, GetLayer(bd, draw_list, 3), plist3, nk3, o3x, o3y, ox, oy, force, z) then list_deferred = true end
elseif set and set[3] then HideLayer(set[3]) end
set = bd.Layers[draw_list]
- if set then set.LastUsed = frame_no; set.Sig = sig end
+ -- (deferred tiles: not "unchanged" next frame even if the draw list is, so they get redrawn)
+ if set then
+ set.LastUsed = frame_no
+ if list_deferred then set.SnapOK = false else
+ Snapshot(set, draw_list, ox, oy, sw, sh)
+ set.SLimFree = lim_free
+ set.SEX0, set.SEY0, set.SEX1, set.SEY1 = ex0, ey0, ex1, ey1
+ end
+ end
+ if list_deferred then any_deferred = true end
z = PlaceOverlays(bd, draw_list, z, ox, oy)
end
@@ -1033,7 +1314,7 @@ function ImGui_ImplRoblox_RenderDrawData(draw_data)
-- Hide layers not drawn this frame; free long unused ones (closed windows, old tooltips)
for draw_list, set in pairs(bd.Layers) do
if set.LastUsed ~= frame_no then
- set.Sig = nil -- hidden now: must go through the full path when shown again
+ set.SnapOK = false -- hidden now: must go through the full path when shown again
for kind = 1, 3 do if set[kind] then HideLayer(set[kind]) end end
if frame_no - set.LastUsed > LAYER_KEEP_FRAMES then
DestroyLayerSet(set)
@@ -1045,6 +1326,8 @@ function ImGui_ImplRoblox_RenderDrawData(draw_data)
-- drop references so old vertex tables can be collected
for k = n + 1, bd.LastPrimCount do pr_t[k], pr_vb[k] = nil, nil end
bd.LastPrimCount = n
+ -- Tiles left for later: if the app doesn't submit a new frame, Heartbeat finishes them from this draw data
+ if any_deferred then bd.Stale = draw_data; bd.StaleHB = bd.HB end
PE()
end
@@ -1070,7 +1353,7 @@ function ImGui_ImplRoblox_Init(parent, render_scale)
local bd = {
Time = os.clock(), Gui = gui, Connections = {},
Textures = {}, TextureCount = 0, MouseX = -1, MouseY = -1,
- Layers = {}, Overlays = {}, OverlayHolders = {}, OverlayShown = {}, FrameNo = 0, Scale = render_scale or 1, TilesActive = false, RenderRate = 60, BusyRenderRate = 0, NextRender = 0, LastPrimCount = 0, ForceRedraw = true,
+ Layers = {}, Overlays = {}, OverlayHolders = {}, OverlayShown = {}, FrameNo = 0, Scale = render_scale or 1, TilesActive = false, RenderRate = 0, BusyRenderRate = 0, RedrawBudget = 0.0015, HB = 0, NextRender = 0, LastPrimCount = 0, ForceRedraw = true,
}
io.BackendPlatformUserData = bd
@@ -1137,7 +1420,10 @@ function ImGui_ImplRoblox_Init(parent, render_scale)
if d and os.clock() + 0.002 >= bd.NextRender then
bd.Pending = nil
ImGui_ImplRoblox_RenderDrawData(d)
+ elseif bd.Stale and bd.HB > bd.StaleHB then -- deferred tiles of a frame not followed by a new one
+ ImGui_ImplRoblox_RenderDrawData(bd.Stale)
end
+ bd.HB += 1
end)
connect(UserInputService.WindowFocused, function() ImGui.GetIO():AddFocusEvent(true) end)
@@ -1200,6 +1486,7 @@ function ImGui_ImplRoblox_NewFrame()
local io = ImGui.GetIO()
local bd = ImGui_ImplRoblox_GetBackendData()
if bd.Pending then bd.Pending = nil; table.clear(bd.Overlays) end -- skipped frame superseded (its draw lists get reset)
+ bd.Stale = nil
local screen = bd.Gui.AbsoluteSize
local w, h = floor(screen.X * bd.Scale), floor(screen.Y * bd.Scale)
@@ -1306,7 +1593,14 @@ function ImGui_ImplRoblox_NewFrame()
PE()
end
---- Max tile redraws per second (default 60); 0 = every frame. imgui logic still runs every frame.
+--- Max milliseconds per frame spent redrawing changed tiles (default 1.5; 0 = no limit). Tiles over the budget keep
+--- their previous pixels and are redrawn on the following frames (each one at most a few frames late), so a big change
+--- (resizing a large window, opening one, scrolling a long list) is spread over frames instead of one long frame.
+function ImGui_ImplRoblox_SetRedrawBudget(ms)
+ ImGui_ImplRoblox_GetBackendData().RedrawBudget = (ms or 0) / 1000
+end
+
+--- Max tile redraws per second (default 0 = every frame, limited by the redraw budget instead). imgui logic still runs every frame.
function ImGui_ImplRoblox_SetRenderRate(hz)
ImGui_ImplRoblox_GetBackendData().RenderRate = hz or 0
end
@@ -1336,6 +1630,7 @@ ImGui_ImplRoblox = {
AddFile = ImGui_ImplRoblox_AddFile,
SetRenderScale = ImGui_ImplRoblox_SetRenderScale,
SetRenderRate = ImGui_ImplRoblox_SetRenderRate,
+ SetRedrawBudget = ImGui_ImplRoblox_SetRedrawBudget,
SetOverlay = ImGui_ImplRoblox_SetOverlay,
SetTextProxy = ImGui_ImplRoblox_SetTextProxy,
CreateTexture = ImGui_ImplRoblox_CreateTexture,
diff --git a/build/ImGui.linemap.json b/build/ImGui.linemap.json
index d389ede..7128ca5 100644
--- a/build/ImGui.linemap.json
+++ b/build/ImGui.linemap.json
@@ -1 +1 @@
-[[12, "imgui.lua"], [21288, "imgui_demo.lua"], [29706, "imgui_draw.lua"], [35476, "imgui_h.lua"], [38142, "imgui_impl_roblox.lua"], [39494, "imgui_impl_roblox_fonts.lua"], [45897, "imgui_internal.lua"], [49343, "imgui_tables.lua"], [54338, "imgui_widgets.lua"], [64948, "imstb_rectpack.lua"], [65612, "imstb_textedit.lua"], [67154, "imstb_truetype.lua"], [69942, "imstd_minstdio.lua"]]
\ No newline at end of file
+[[12, "imgui.lua"], [21288, "imgui_demo.lua"], [29706, "imgui_draw.lua"], [35502, "imgui_h.lua"], [38168, "imgui_impl_roblox.lua"], [39815, "imgui_impl_roblox_fonts.lua"], [46218, "imgui_internal.lua"], [49664, "imgui_tables.lua"], [54659, "imgui_widgets.lua"], [65269, "imstb_rectpack.lua"], [65933, "imstb_textedit.lua"], [67475, "imstb_truetype.lua"], [70263, "imstd_minstdio.lua"]]
\ No newline at end of file
diff --git a/build/ImGui.luau b/build/ImGui.luau
index c7d9480..49c23ca 100644
--- a/build/ImGui.luau
+++ b/build/ImGui.luau
@@ -32574,6 +32574,7 @@ end
--- @param wrap_width float
--- @param flags ImDrawTextFlags
--- @return int
+@native
function ImFontCalcWordWrapPositionEx(font, size, text, pos, text_end, wrap_width, flags)
-- For references, possible wrap point marked with ^
-- "aaa bbb, ccc,ddd. eee fff. ggg!"
@@ -32604,10 +32605,12 @@ function ImFontCalcWordWrapPositionEx(font, size, text, pos, text_end, wrap_widt
local span_width = 0.0
local stop = false
+ local text_is_str = type(text) == "string" -- [Luau] ImStrByte() inlined (once per character)
while s < text_end do
repeat
--- @type unsigned_int
- local c = ImStrByte(text, s)
+ local c
+ if text_is_str then c = string.byte(text, s) else c = text[s] end
local next_s
if c < 0x80 then
next_s = s + 1
@@ -32636,8 +32639,8 @@ function ImFontCalcWordWrapPositionEx(font, size, text, pos, text_end, wrap_widt
-- Classify current character
local curr_type
- if c < 128 then
- curr_type = ImTextClassifierGet(g_CharClassifierIsSeparator_0000_007f, c)
+ if c < 128 then -- [Luau] ImTextClassifierGet() inlined
+ curr_type = bit32.band(bit32.rshift(g_CharClassifierIsSeparator_0000_007f[bit32.rshift(c, 4) + 1], bit32.lshift(bit32.band(c, 15), 1)), 0x03)
elseif (c >= 0x3000 and c < 0x3010) then
curr_type = ImTextClassifierGet(g_CharClassifierIsSeparator_3000_300f, bit32.band(c, 15))
else
@@ -32716,9 +32719,11 @@ local TEXT_SIZE_CACHE_MAX = 4096
@native
function ImFontCalcTextSizeEx(font, size, max_width, wrap_width, text, text_begin, text_end_display, text_end, out_offset, flags)
+ local text_is_str = type(text) == "string"
-- [Luau] cache for the common case (whole string, single pass, no wrap/limit): per baked font, text -> unscaled width, line count.
-- Advances of a baked font never change once loaded, so entries stay valid. ponytail: whole-cache reset at TEXT_SIZE_CACHE_MAX entries, LRU if dynamic text churns it
local cache_key = nil
+ local wrap_cache = nil
if text_begin == 1 and out_offset == nil and wrap_width <= 0.0 and max_width >= FLT_MAX and type(text) == "string"
and (text_end == nil or text_end == #text + 1) and (text_end_display == nil or text_end_display == #text + 1) and (flags == nil or bit32.band(flags, ImDrawTextFlags.StopOnNewLine) == 0) then
local baked = font:GetFontBaked(size)
@@ -32731,6 +32736,23 @@ function ImFontCalcTextSizeEx(font, size, max_width, wrap_width, text, text_begi
return ImVec2(e[1] * (size / baked.Size), e[2] * size), #text + 1
end
cache_key = text
+ elseif text_begin == 1 and out_offset == nil and wrap_width > 0.0 and max_width >= FLT_MAX and text_is_str
+ and (text_end == nil or text_end == #text + 1) and (text_end_display == nil or text_end_display == #text + 1) and (flags == nil or bit32.band(flags, ImDrawTextFlags.StopOnNewLine) == 0) then
+ -- [Luau] same for wrapped text (TextWrapped() measures every line every frame): exact results per (size, wrap width, text)
+ local baked = font:GetFontBaked(size)
+ local cache = baked._TextSizeCache
+ if cache == nil or baked._TextSizeCacheN >= TEXT_SIZE_CACHE_MAX then
+ cache = {}; baked._TextSizeCache = cache; baked._TextSizeCacheN = 0
+ end
+ local by_size = cache[size]
+ if by_size == nil then by_size = {}; cache[size] = by_size end
+ local by_wrap = by_size[wrap_width]
+ if by_wrap == nil then by_wrap = {}; by_size[wrap_width] = by_wrap end
+ local e = by_wrap[text]
+ if e then
+ return ImVec2(e[1], e[2]), e[3]
+ end
+ wrap_cache = by_wrap
end
if not text_end then
text_end = #text + 1 -- FIXME-OPT: Need to avoid this.
@@ -32778,7 +32800,8 @@ function ImFontCalcTextSizeEx(font, size, max_width, wrap_width, text, text_begi
-- Decode and advance source
local prev_s = s
- local c = ImStrByte(text, s)
+ local c
+ if text_is_str then c = string.byte(text, s) else c = text[s] end -- [Luau] ImStrByte() inlined
if c < 0x80 then
s = s + 1
else
@@ -32838,6 +32861,9 @@ function ImFontCalcTextSizeEx(font, size, max_width, wrap_width, text, text_begi
if cache_key then
baked._TextSizeCache[cache_key] = { text_size.x / scale, text_size.y / line_height }
baked._TextSizeCacheN = baked._TextSizeCacheN + 1
+ elseif wrap_cache then
+ wrap_cache[text] = { text_size.x, text_size.y, out_remaining }
+ baked._TextSizeCacheN = baked._TextSizeCacheN + 1
end
return text_size, out_remaining
@@ -38283,6 +38309,7 @@ end
---------------------------------------------------------
-- Copies a region of ImTextureData pixels (1-based byte table) into our RGBA8 buffer
+@native
local function CopyTexturePixels(tex, dst, x, y, w, h)
local bpp, tex_w = tex.BytesPerPixel, tex.Width
for row = 0, h - 1 do
@@ -38349,6 +38376,8 @@ end
-- Blending is non-premultiplied "over" with a transparent (alpha 0) background, so the game stays visible behind ImGui.
local buf, buf_w -- buffer/width of the tile being drawn
+local rect_cols = table.create(256, 0) -- scratch: texel column offsets for DrawRect
+local sub_bufs, n_sub_bufs = {}, 0 -- scratch: partial tile upload buffers by size
local TILE_SIZE -- Vector2, set below
-- Blends one pixel (r, g, b, a are 0..255). One u32 read/write, one division in the general case.
@@ -38478,18 +38507,30 @@ local function DrawRect(x0, y0, x1, y1, u0, v0, u1, v1, col, t, cx0, cy0, cx1, c
local du, dv = (u1 - u0) / (x1 - x0), (v1 - v0) / (y1 - y0)
local tw, th, tbuf = t.Width, t.Height, t.Buffer
local r255, g255, b255, a255 = r / 255, g / 255, b / 255, a / 255
+ -- The texel column only depends on x: compute it once per column instead of once per pixel
+ local ncol = px1 - px0
+ local cols = rect_cols
+ for c = 1, ncol do
+ local tx = floor((u0 + (px0 + c - 1 + 0.5 - x0) * du) * tw)
+ if tx < 0 then tx = 0 elseif tx >= tw then tx = tw - 1 end
+ cols[c] = tx * 4
+ end
for y = py0, py1 - 1 do
local ty = floor((v0 + (y + 0.5 - y0) * dv) * th)
if ty < 0 then ty = 0 elseif ty >= th then ty = th - 1 end
- local trow = ty * tw
+ local trow4 = ty * tw * 4
local o = (y * buf_w + px0) * 4
- for x = px0, px1 - 1 do
- local tx = floor((u0 + (x + 0.5 - x0) * du) * tw)
- if tx < 0 then tx = 0 elseif tx >= tw then tx = tw - 1 end
- local to = (trow + tx) * 4
+ for c = 1, ncol do
+ local to = trow4 + cols[c]
local ta = readu8(tbuf, to + 3)
if ta > 0 then
- BlendPixel(o, readu8(tbuf, to) * r255, readu8(tbuf, to + 1) * g255, readu8(tbuf, to + 2) * b255, ta * a255)
+ local sa = ta * a255
+ local sr, sg, sb = readu8(tbuf, to) * r255, readu8(tbuf, to + 1) * g255, readu8(tbuf, to + 2) * b255
+ if sa >= 255 then -- (BlendPixel's opaque case, inlined: most glyph texels)
+ writeu32(buf, o, floor(sr + 0.5) + floor(sg + 0.5) * 256 + floor(sb + 0.5) * 65536 + 4278190080)
+ else
+ BlendPixel(o, sr, sg, sb, sa)
+ end
end
o = o + 4
end
@@ -38583,6 +38624,7 @@ local P1, P2 = 2147483647, 2147483629 -- two independent 31 bit hashes per tile
local KEY_W = 64 -- tile key = ty * KEY_W + tx (layers up to 16384 px wide)
local CLIP_PAD = 2048 -- content up to this far off screen is kept, so dragging a window past the edge doesn't redraw it
local LAYER_KEEP_FRAMES = 120 -- unused layers are destroyed after this many rendered frames
+local MAX_DEFER_FRAMES = 8 -- a tile is redrawn at the latest this many frames after it changed, even over the redraw budget
-- Primitive arrays (reused between frames, no per-frame allocation)
local pr_a, pr_b, pr_c, pr_d, pr_t, pr_g = {}, {}, {}, {}, {}, {} -- vertices (pr_d == false -> triangle), texture, gradient kind
@@ -38592,6 +38634,7 @@ local pr_tex = {} -- texture id
local pr_vb = {} -- vertex buffer (draw list VtxBuffer.Buf)
local pr_k = {} -- layer kind (1..3)
local plist1, plist2, plist3 = {}, {}, {} -- primitive indices per layer kind
+local cm_free, cm_kind, cm_x0, cm_y0, cm_x1, cm_y1 = {}, {}, {}, {}, {}, {} -- per draw command (collect -> Snapshot)
@native
local function HashVertex(h1, h2, vbuf, v, ox, oy)
@@ -38681,7 +38724,7 @@ local function GetLayer(bd, draw_list, kind)
else
frame = NewFrame(bd.Gui, false); root = frame
end
- layer = { Root = root, Frame = frame, Tiles = {}, TH1 = {}, TH2 = {}, TN = {}, TL = {}, TX = {}, TY = {}, Touched = {}, NTouched = 0,
+ layer = { Root = root, Frame = frame, Tiles = {}, TH1 = {}, TH2 = {}, TN = {}, TL = {}, TL1 = {}, TL2 = {}, TLF = {}, TX = {}, TY = {}, Touched = {}, NTouched = 0,
Visible = false, X = false, Y = false, Z = -1, CX0 = false, CY0 = false, CX1 = false, CY1 = false }
set[kind] = layer
return layer
@@ -38708,7 +38751,8 @@ local function GetTile(bd, layer, key, tx, ty)
label.Visible = false
label.Active = bd.TilesActive
label.Parent = layer.Frame
- tile = { Image = img, Label = label, Buffer = buffer.create(TILE * TILE * 4), H1 = -1, H2 = -1, Visible = false, BX0 = TILE, BY0 = TILE, BX1 = 0, BY1 = 0 }
+ tile = { Image = img, Label = label, Buffer = buffer.create(TILE * TILE * 4), H1 = -1, H2 = -1, Visible = false, BX0 = TILE, BY0 = TILE, BX1 = 0, BY1 = 0,
+ ON = -1, OH1 = {}, OH2 = {}, OF = {}, OB0 = {}, OB1 = {}, OB2 = {}, OB3 = {} } -- last drawn primitive list (hashes, bounds in tile)
layer.Tiles[key] = tile
return tile
end
@@ -38728,31 +38772,129 @@ local function DestroyTiles(bd)
bd.Layers = {}
end
--- Exact signature of everything in a draw list that affects its layers (nil = can't skip: user callbacks)
-local sig_buf = buffer.create(4096)
+-- Snapshot of everything in a draw list that affects its layers, compared exactly with the next frame's list:
+-- 1 = identical (only restack), 2 = everything moved by the same whole pixels (dx, dy) (only move the layers: a window
+-- being dragged), 0 = changed (full path). Compared in place (no strings built): early exit at the first difference.
+local writef64 = buffer.writef64
+local CMD_STRIDE = 70 -- clip (4 f64), ElemCount, IdxOffset, VtxOffset (u32), tex id (f64), kind (u8), free (u8), bounds of its primitives (4 x i32)
+
@native
-local function DrawListSignature(draw_list, ox, oy)
+local function Snapshot(set, draw_list, ox, oy, sw, sh)
+ local nv = draw_list.VtxBuffer.Size * VTX_STRIDE
local idx, ni = draw_list.IdxBuffer.Data, draw_list.IdxBuffer.Size
local ncmd = draw_list.CmdBuffer.Size
- local need = ni * 4 + ncmd * 64 + 64
- if buffer.len(sig_buf) < need then sig_buf = buffer.create(need * 2) end
- local b, o = sig_buf, 0
- for i = 1, ni do writeu32(b, o, idx[i]); o += 4 end
+ local sv, si, sc = set.SV, set.SI, set.SC
+ if not sv or buffer.len(sv) < nv then sv = buffer.create(max(nv, 64) * 2); set.SV = sv end
+ if not si or buffer.len(si) < ni * 4 then si = buffer.create(max(ni, 16) * 8); set.SI = si end
+ if not sc or buffer.len(sc) < ncmd * CMD_STRIDE then sc = buffer.create(max(ncmd, 4) * CMD_STRIDE * 2); set.SC = sc end
+ buffer.copy(sv, 0, draw_list.VtxBuffer.Buf, 0, nv)
+ for i = 1, ni do writeu32(si, (i - 1) * 4, idx[i]) end
+ local o, ci = 0, 0
for _, pcmd in draw_list.CmdBuffer:iter() do
- if pcmd.UserCallback ~= nil then return nil end
+ if pcmd.UserCallback ~= nil then set.SnapOK = false; return end
local c = pcmd.ClipRect
- buffer.writef64(b, o, c.x); buffer.writef64(b, o + 8, c.y); buffer.writef64(b, o + 16, c.z); buffer.writef64(b, o + 24, c.w)
- writeu32(b, o + 32, pcmd.ElemCount); writeu32(b, o + 36, pcmd.IdxOffset); writeu32(b, o + 40, pcmd.VtxOffset)
- buffer.writef64(b, o + 44, pcmd:GetTexID())
- o += 52
+ writef64(sc, o, c.x); writef64(sc, o + 8, c.y); writef64(sc, o + 16, c.z); writef64(sc, o + 24, c.w)
+ writeu32(sc, o + 32, pcmd.ElemCount); writeu32(sc, o + 36, pcmd.IdxOffset); writeu32(sc, o + 40, pcmd.VtxOffset)
+ writef64(sc, o + 44, pcmd:GetTexID())
+ ci += 1
+ buffer.writeu8(sc, o + 52, cm_kind[ci] or 0); buffer.writeu8(sc, o + 53, cm_free[ci] and 1 or 0)
+ if cm_free[ci] then
+ buffer.writei32(sc, o + 54, cm_x0[ci]); buffer.writei32(sc, o + 58, cm_y0[ci]); buffer.writei32(sc, o + 62, cm_x1[ci]); buffer.writei32(sc, o + 66, cm_y1[ci])
+ end
+ o += CMD_STRIDE
+ end
+ set.SVN, set.SIN, set.SCN = nv, ni, ncmd
+ set.SOX, set.SOY, set.SSW, set.SSH = ox, oy, sw, sh
+ set.SCC = draw_list._ContentClipX0 ~= nil
+ set.SCX0, set.SCY0, set.SCX1, set.SCY1 = draw_list._ContentClipX0 or 0, draw_list._ContentClipY0 or 0, draw_list._ContentClipX1 or 0, draw_list._ContentClipY1 or 0
+ set.SSX, set.SSY = draw_list._ScrollOriginX or 0, draw_list._ScrollOriginY or 0
+ set.SnapOK = true
+end
+
+-- a + d == b exactly, with no rounding in a + d (so every later "x - origin" gives the same result as before the move)
+@native
+local function ShiftedExact(a, d, b)
+ local s = a + d
+ if s ~= b then return false end
+ local bb = s - a
+ return (a - (s - bb)) + (d - bb) == 0
+end
+
+@native
+local function CompareDrawList(set, draw_list, ox, oy, sw, sh)
+ if not set or not set.SnapOK then return 0 end
+ local nv = draw_list.VtxBuffer.Size * VTX_STRIDE
+ local ni, ncmd = draw_list.IdxBuffer.Size, draw_list.CmdBuffer.Size
+ if nv ~= set.SVN or ni ~= set.SIN or ncmd ~= set.SCN or ox ~= set.SOX or oy ~= set.SOY or sw ~= set.SSW or sh ~= set.SSH then return 0 end
+ if (draw_list._ContentClipX0 ~= nil) ~= set.SCC then return 0 end
+ local vb, sv = draw_list.VtxBuffer.Buf, set.SV
+ if nv == 0 then return 1 end
+ -- translation from the first vertex: whole pixels only, and only with DisplayPos at 0 (so it stays exact)
+ local dx, dy = readf64(vb, 0) - readf64(sv, 0), readf64(vb, 8) - readf64(sv, 8)
+ if dx ~= dx or dy ~= dy then return 0 end
+ if dx ~= 0 or dy ~= 0 then
+ if dx ~= floor(dx) or dy ~= floor(dy) or ox ~= 0 or oy ~= 0 or not set.SLimFree then return 0 end
+ -- everything must stay as far from the clip limits as before (nothing newly clamped)
+ if set.SEX0 + dx < -CLIP_PAD or set.SEY0 + dy < -CLIP_PAD or set.SEX1 + dx > sw + CLIP_PAD or set.SEY1 + dy > sh + CLIP_PAD then return 0 end
+ end
+ local idx, si = draw_list.IdxBuffer.Data, set.SI
+ for i = 1, ni do if idx[i] ~= readu32(si, (i - 1) * 4) then return 0 end end
+ local sc, o = set.SC, 0
+ local ccx0, ccy0, ccx1, ccy1 = draw_list._ContentClipX0, draw_list._ContentClipY0, draw_list._ContentClipX1, draw_list._ContentClipY1
+ local seen_content = false
+ for _, pcmd in draw_list.CmdBuffer:iter() do
+ if pcmd.UserCallback ~= nil then return 0 end
+ if pcmd.ElemCount ~= readu32(sc, o + 32) or pcmd.IdxOffset ~= readu32(sc, o + 36) or pcmd.VtxOffset ~= readu32(sc, o + 40) or pcmd:GetTexID() ~= readf64(sc, o + 44) then return 0 end
+ local c = pcmd.ClipRect
+ if dx == 0 and dy == 0 then
+ if c.x ~= readf64(sc, o) or c.y ~= readf64(sc, o + 8) or c.z ~= readf64(sc, o + 16) or c.w ~= readf64(sc, o + 24) then return 0 end
+ elseif not (ShiftedExact(readf64(sc, o), dx, c.x) and ShiftedExact(readf64(sc, o + 8), dy, c.y)
+ and ShiftedExact(readf64(sc, o + 16), dx, c.z) and ShiftedExact(readf64(sc, o + 24), dy, c.w)) then
+ -- An unchanged clip rect (e.g. the viewport) is fine if it clipped none of this command's primitives before
+ -- the move and still clips none after it
+ if c.x ~= readf64(sc, o) or c.y ~= readf64(sc, o + 8) or c.z ~= readf64(sc, o + 16) or c.w ~= readf64(sc, o + 24)
+ or buffer.readu8(sc, o + 53) == 0 then return 0 end
+ if buffer.readi32(sc, o + 54) + dx < floor(c.x) or buffer.readi32(sc, o + 58) + dy < floor(c.y)
+ or buffer.readi32(sc, o + 62) + dx > floor(c.z) or buffer.readi32(sc, o + 66) + dy > floor(c.w) then return 0 end
+ end
+ -- same layer kind as before (same rule as the collect pass)
+ if pcmd.ElemCount > 0 then
+ local kind = 1
+ if ccx0 and c.x >= ccx0 and c.y >= ccy0 and c.z <= ccx1 and c.w <= ccy1 then kind = 2; seen_content = true
+ elseif seen_content then kind = 3 end
+ if kind ~= buffer.readu8(sc, o + 52) then return 0 end
+ end
+ o += CMD_STRIDE
+ end
+ if dx == 0 and dy == 0 then
+ if set.SCC and (draw_list._ContentClipX0 ~= set.SCX0 or draw_list._ContentClipY0 ~= set.SCY0 or draw_list._ContentClipX1 ~= set.SCX1
+ or draw_list._ContentClipY1 ~= set.SCY1 or draw_list._ScrollOriginX ~= set.SSX or draw_list._ScrollOriginY ~= set.SSY) then return 0 end
+ for v = 0, nv - VTX_STRIDE, VTX_STRIDE do
+ if readf64(vb, v) ~= readf64(sv, v) or readf64(vb, v + 8) ~= readf64(sv, v + 8) or readf64(vb, v + 16) ~= readf64(sv, v + 16)
+ or readf64(vb, v + 24) ~= readf64(sv, v + 24) or readu32(vb, v + 32) ~= readu32(sv, v + 32) then return 0 end
+ end
+ return 1
+ end
+ if set.SCC and not (ShiftedExact(set.SCX0, dx, draw_list._ContentClipX0) and ShiftedExact(set.SCY0, dy, draw_list._ContentClipY0)
+ and ShiftedExact(set.SCX1, dx, draw_list._ContentClipX1) and ShiftedExact(set.SCY1, dy, draw_list._ContentClipY1)
+ and ShiftedExact(set.SSX, dx, draw_list._ScrollOriginX) and ShiftedExact(set.SSY, dy, draw_list._ScrollOriginY)) then return 0 end
+ for v = 0, nv - VTX_STRIDE, VTX_STRIDE do
+ if not ShiftedExact(readf64(sv, v), dx, readf64(vb, v)) or not ShiftedExact(readf64(sv, v + 8), dy, readf64(vb, v + 8))
+ or readf64(vb, v + 16) ~= readf64(sv, v + 16) or readf64(vb, v + 24) ~= readf64(sv, v + 24) or readu32(vb, v + 32) ~= readu32(sv, v + 32) then return 0 end
+ end
+ return 2, dx, dy
+end
+
+-- A window moved by whole pixels: its tiles are unchanged, only the layer frames move
+local function ShiftLayer(layer, dx, dy, scale)
+ layer.X += dx; layer.Y += dy
+ local root = layer.Root
+ if root ~= layer.Frame then
+ layer.CX0 += dx; layer.CY0 += dy; layer.CX1 += dx; layer.CY1 += dy
+ root.Position = UDim2.fromOffset(layer.CX0 / scale, layer.CY0 / scale)
+ else
+ root.Position = UDim2.fromOffset(layer.X / scale, layer.Y / scale)
end
- buffer.writef64(b, o, ox); buffer.writef64(b, o + 8, oy)
- buffer.writef64(b, o + 16, draw_list._ContentClipX0 or -1); buffer.writef64(b, o + 24, draw_list._ContentClipY0 or -1)
- buffer.writef64(b, o + 32, draw_list._ContentClipX1 or -1); buffer.writef64(b, o + 40, draw_list._ContentClipY1 or -1)
- buffer.writef64(b, o + 48, draw_list._ContentClipX0 and draw_list._ScrollOriginX or -1)
- buffer.writef64(b, o + 56, draw_list._ContentClipX0 and draw_list._ScrollOriginY or -1)
- o += 64
- return readstring(draw_list.VtxBuffer.Buf, 0, draw_list.VtxBuffer.Size * VTX_STRIDE) .. readstring(b, 0, o)
end
-- Overlays: user Instances (e.g. a Frame holding a ViewportFrame) placed inside a window, registered every frame with
@@ -38831,9 +38973,11 @@ local function PE() if profiling then profileend() end end
@native
local function DrawLayer(bd, layer, plist, np, lx0, ly0, ox, oy, force, z, clip_x0, clip_y0, clip_x1, clip_y1)
local scale = bd.Scale
+ local deferred = false
-- Pass 2: hash primitives into layer-relative tiles
PB("ImGui tile hash")
local th1, th2, tn, tl, touched = layer.TH1, layer.TH2, layer.TN, layer.TL, layer.Touched
+ local tl1, tl2, tlf = layer.TL1, layer.TL2, layer.TLF -- per-tile list of the primitives' hashes (for partial redraws)
for k = 1, layer.NTouched do tn[touched[k]] = 0 end
local nt = 0
for pi = 1, np do
@@ -38842,23 +38986,55 @@ local function DrawLayer(bd, layer, plist, np, lx0, ly0, ox, oy, force, z, clip_
-- The clip rect only affects pixels inside the primitive's bounds, so hash the clipped bounds (relative)
-- instead of the clip itself: a window clipped by the (fixed) viewport rect stays hash-stable when moved.
local cx0, cy0, cx1, cy1 = pr_bx0[p] - lx0, pr_by0[p] - ly0, pr_bx1[p] - lx0, pr_by1[p] - ly0
- local h1 = ((cx0 * 8191 + cy0) * 8191 + cx1) % P1
- local h2 = ((cy1 * 8191 + cx1) * 8191 + cy0 + tex_id) % P2
- local vax, vay = lx0 + ox, ly0 + oy
local vbuf = pr_vb[p]
- h1, h2 = HashVertex(h1, h2, vbuf, pr_a[p], vax, vay)
- h1, h2 = HashVertex(h1, h2, vbuf, pr_b[p], vax, vay)
- h1, h2 = HashVertex(h1, h2, vbuf, pr_c[p], vax, vay)
- local vd = pr_d[p]
- if vd then h1, h2 = HashVertex(h1, h2, vbuf, vd, vax, vay) end
+ local va, vd = pr_a[p], pr_d[p]
+ -- Flat rect (single colour, no gradient, constant uv): its pixels in a tile are exactly its clipped pixel rect
+ -- inside that tile filled with one colour, so hash that per tile instead of the vertices. Resizing a window
+ -- then only changes the tiles its edges cross, not every tile its background covers.
+ local flat_rect, rcol, ruv = false, 0, 0
+ if vd and not pr_g[p] then
+ local u0, v0 = readf64(vbuf, va + 16), readf64(vbuf, va + 24)
+ local vc = pr_c[p]
+ if not pr_t[p] or (u0 == readf64(vbuf, vc + 16) and v0 == readf64(vbuf, vc + 24)) then
+ flat_rect, rcol, ruv = true, readu32(vbuf, va + 32), u0 * 8192 + v0
+ end
+ end
+ local h1, h2 = 0, 0
+ local fsig = -1 -- flat rect: colour/uv/texture without the geometry (-1: not a flat rect)
+ if flat_rect then fsig = ((rcol * 8191 + ruv * 4096) % P1 * 64 + tex_id) % P1 end
+ if not flat_rect then
+ h1 = ((cx0 * 8191 + cy0) * 8191 + cx1) % P1
+ h2 = ((cy1 * 8191 + cx1) * 8191 + cy0 + tex_id) % P2
+ local vax, vay = lx0 + ox, ly0 + oy
+ if vd then
+ -- Axis aligned quad: the collect pass checked that TR/BL (positions, uvs, colours) follow from TL/BR
+ -- (and the gradient kind), so TL + BR + kind determine its pixels
+ h1, h2 = HashVertex(h1, h2, vbuf, va, vax, vay)
+ h1, h2 = HashVertex(h1, h2, vbuf, pr_c[p], vax, vay)
+ local gk = pr_g[p]
+ if gk then h1 = (h1 * 48271 + gk) % P1; h2 = (h2 * 16807 + gk * 3) % P2 end
+ else
+ h1, h2 = HashVertex(h1, h2, vbuf, va, vax, vay)
+ h1, h2 = HashVertex(h1, h2, vbuf, pr_b[p], vax, vay)
+ h1, h2 = HashVertex(h1, h2, vbuf, pr_c[p], vax, vay)
+ end
+ end
for ty = floor((pr_by0[p] - ly0) / TILE), floor((pr_by1[p] - 1 - ly0) / TILE) do
for tx = floor((pr_bx0[p] - lx0) / TILE), floor((pr_bx1[p] - 1 - lx0) / TILE) do
+ if flat_rect then
+ local tx0, ty0 = tx * TILE, ty * TILE
+ local ix0, iy0 = max(cx0 - tx0, 0), max(cy0 - ty0, 0)
+ local ix1, iy1 = min(cx1 - tx0, TILE), min(cy1 - ty0, TILE)
+ local r = ((ix0 * 131 + iy0) * 131 + ix1) * 131 + iy1
+ h1 = ((r * 48271 + rcol) % P1 * 48271 + ruv * 4096 + 7) % P1
+ h2 = ((r * 16807 + rcol + tex_id) % P2 * 16807 + ruv * 65521 + 11) % P2
+ end
local key = ty * KEY_W + tx
local k = tn[key]
if not k or k == 0 then
k = 0
th1[key], th2[key] = 0, 0
- if not tl[key] then tl[key] = {} end
+ if not tl[key] then tl[key] = {}; tl1[key] = {}; tl2[key] = {}; tlf[key] = {} end
nt = nt + 1
touched[nt] = key
layer.TX[key], layer.TY[key] = tx, ty
@@ -38866,6 +39042,7 @@ local function DrawLayer(bd, layer, plist, np, lx0, ly0, ox, oy, force, z, clip_
k = k + 1
tn[key] = k
tl[key][k] = p
+ tl1[key][k], tl2[key][k], tlf[key][k] = h1, h2, fsig
th1[key] = (th1[key] * 48271 + h1) % P1
th2[key] = (th2[key] * 16807 + h2 + k) % P2
end
@@ -38899,7 +39076,7 @@ local function DrawLayer(bd, layer, plist, np, lx0, ly0, ox, oy, force, z, clip_
PB("ImGui raster+upload")
for key, tile in pairs(layer.Tiles) do
if not tn[key] or tn[key] == 0 then
- if tile.Visible then tile.Visible = false; tile.Label.Visible = false; tile.H1 = -1 end
+ if tile.Visible then tile.Visible = false; tile.Label.Visible = false; tile.H1 = -1; tile.ON = -1 end
end
end
for kk = 1, nt do
@@ -38907,14 +39084,27 @@ local function DrawLayer(bd, layer, plist, np, lx0, ly0, ox, oy, force, z, clip_
local count = tn[key]
local tx, ty = layer.TX[key], layer.TY[key]
local tile = GetTile(bd, layer, key, tx, ty)
- if force or tile.H1 ~= th1[key] or tile.H2 ~= th2[key] then
+ local dirty = force or tile.H1 ~= th1[key] or tile.H2 ~= th2[key]
+ if dirty and bd.RedrawStarted and (tile.Deferred or 0) < MAX_DEFER_FRAMES
+ and ((bd.RedrawBudget > 0 and os.clock() - bd.FrameT0 >= bd.RedrawBudget) or (bd.TileLimit and bd.FrameTiles >= bd.TileLimit)) then -- (TileLimit: test hook, tools/test/scripts/t_pixels.luau)
+ -- Over this frame's redraw budget: keep showing the old pixels, redraw on a following frame. The tile's
+ -- stored hashes/list still describe what it shows, so the next comparison is against the real content.
+ tile.Deferred = (tile.Deferred or 0) + 1
+ if force then tile.H1 = -1; tile.ON = -1 end -- full redraw later (force doesn't persist)
+ deferred = true
+ dirty = false
+ end
+ if dirty then
+ bd.RedrawStarted = true
+ bd.FrameTiles += 1
+ tile.Deferred = 0
tile.H1, tile.H2 = th1[key], th2[key]
local x0, y0 = lx0 + tx * TILE, ly0 + ty * TILE
local x1, y1 = x0 + TILE, y0 + TILE
buf, buf_w = tile.Buffer, TILE
- local list = tl[key]
- -- Dirty region = old content bounds + new content bounds. Outside it the tile is already
- -- transparent, so only this region is cleared and uploaded (edge tiles are often mostly empty).
+ local list, lh1, lh2 = tl[key], tl1[key], tl2[key]
+ local oh1, oh2, ob0, ob1, ob2, ob3, on = tile.OH1, tile.OH2, tile.OB0, tile.OB1, tile.OB2, tile.OB3, tile.ON
+ -- New content bounds (union of the primitives' bounds in this tile)
local nx0, ny0, nx1, ny1 = TILE, TILE, 0, 0
for k = 1, count do
local p = list[k]
@@ -38926,8 +39116,63 @@ local function DrawLayer(bd, layer, plist, np, lx0, ly0, ox, oy, force, z, clip_
if d > ny1 then ny1 = d end
end
nx0, ny0, nx1, ny1 = max(nx0, 0), max(ny0, 0), min(nx1, TILE), min(ny1, TILE)
- local rx0, ry0, rx1, ry1 = min(nx0, tile.BX0), min(ny0, tile.BY0), max(nx1, tile.BX1), max(ny1, tile.BY1)
+ local rx0, ry0, rx1, ry1
+ if force or on < 0 then
+ -- Dirty region = old content bounds + new content bounds. Outside it the tile is already
+ -- transparent, so only this region is cleared and uploaded (edge tiles are often mostly empty).
+ rx0, ry0, rx1, ry1 = min(nx0, tile.BX0), min(ny0, tile.BY0), max(nx1, tile.BX1), max(ny1, tile.BY1)
+ else
+ -- Partial redraw: the primitives before the first and after the last changed one are the same as last
+ -- time. A pixel that no changed primitive (old or new) touches is covered by the same primitives in the
+ -- same order, so it is already correct: only the bounds of the changed ones are cleared and redrawn.
+ local i, lim = 1, min(on, count)
+ while i <= lim and oh1[i] == lh1[i] and oh2[i] == lh2[i] do i += 1 end
+ local j = 0
+ while j <= lim - i and oh1[on - j] == lh1[count - j] and oh2[on - j] == lh2[count - j] do j += 1 end
+ rx0, ry0, rx1, ry1 = TILE, TILE, 0, 0
+ local of, lf = tile.OF, tlf[key]
+ local paired = on - j - i == count - j - i
+ for k = i, count - j do
+ local p = list[k]
+ local a, b, c, d = max(pr_bx0[p] - x0, 0), max(pr_by0[p] - y0, 0), min(pr_bx1[p] - x0, TILE), min(pr_by1[p] - y0, TILE)
+ local oa, ob, oc, od = ob0[k], ob1[k], ob2[k], ob3[k]
+ if paired and lf[k] >= 0 and lf[k] == of[k] then
+ -- Same flat rect (colour/uv/texture) with new bounds: pixels inside both are unchanged, only the
+ -- strips between the old and new edges can differ (e.g. a window background growing on resize)
+ local uy0, uy1 = min(b, ob), max(d, od)
+ local ux0, ux1 = min(a, oa), max(c, oc)
+ if a ~= oa then ux0 = min(a, oa); local e = max(a, oa); rx0 = min(rx0, ux0); rx1 = max(rx1, e); ry0 = min(ry0, uy0); ry1 = max(ry1, uy1) end
+ if c ~= oc then local e = min(c, oc); rx0 = min(rx0, e); rx1 = max(rx1, max(c, oc)); ry0 = min(ry0, uy0); ry1 = max(ry1, uy1) end
+ if b ~= ob then ry0 = min(ry0, min(b, ob)); ry1 = max(ry1, max(b, ob)); rx0 = min(rx0, ux0); rx1 = max(rx1, ux1) end
+ if d ~= od then ry0 = min(ry0, min(d, od)); ry1 = max(ry1, max(d, od)); rx0 = min(rx0, ux0); rx1 = max(rx1, ux1) end
+ else
+ if a < rx0 then rx0 = a end
+ if b < ry0 then ry0 = b end
+ if c > rx1 then rx1 = c end
+ if d > ry1 then ry1 = d end
+ if k <= on - j then
+ if oa < rx0 then rx0 = oa end
+ if ob < ry0 then ry0 = ob end
+ if oc > rx1 then rx1 = oc end
+ if od > ry1 then ry1 = od end
+ end
+ end
+ end
+ for k = count - j + 1, on - j do -- old middle longer than the new one
+ if ob0[k] < rx0 then rx0 = ob0[k] end
+ if ob1[k] < ry0 then ry0 = ob1[k] end
+ if ob2[k] > rx1 then rx1 = ob2[k] end
+ if ob3[k] > ry1 then ry1 = ob3[k] end
+ end
+ end
tile.BX0, tile.BY0, tile.BX1, tile.BY1 = nx0, ny0, nx1, ny1
+ -- Remember this list for the next partial redraw
+ for k = 1, count do
+ local p = list[k]
+ oh1[k], oh2[k], tile.OF[k] = lh1[k], lh2[k], tlf[key][k]
+ ob0[k], ob1[k], ob2[k], ob3[k] = max(pr_bx0[p] - x0, 0), max(pr_by0[p] - y0, 0), min(pr_bx1[p] - x0, TILE), min(pr_by1[p] - y0, TILE)
+ end
+ tile.ON = count
local rw = rx1 - rx0
if rw == TILE then
buffer.fill(buf, ry0 * TILE * 4, 0, (ry1 - ry0) * TILE * 4)
@@ -38937,8 +39182,10 @@ local function DrawLayer(bd, layer, plist, np, lx0, ly0, ox, oy, force, z, clip_
local tox, toy = ox + x0, oy + y0
for k = 1, count do
local p = list[k]
- local cx0, cy0 = max(pr_cx0[p], x0) - x0, max(pr_cy0[p], y0) - y0
- local cx1, cy1 = min(pr_cx1[p], x1) - x0, min(pr_cy1[p], y1) - y0
+ -- clip to the dirty region (only those pixels are redrawn)
+ local cx0, cy0 = max(pr_cx0[p] - x0, rx0), max(pr_cy0[p] - y0, ry0)
+ local cx1, cy1 = min(pr_cx1[p] - x0, rx1), min(pr_cy1[p] - y0, ry1)
+ if cx0 >= cx1 or cy0 >= cy1 or pr_bx0[p] - x0 >= rx1 or pr_by0[p] - y0 >= ry1 or pr_bx1[p] - x0 <= rx0 or pr_by1[p] - y0 <= ry0 then continue end
local va, vc, vd, vbuf = pr_a[p], pr_c[p], pr_d[p], pr_vb[p]
if vd then
local ax, ay, cx, cy = readf64(vbuf, va) - tox, readf64(vbuf, va + 8) - toy, readf64(vbuf, vc) - tox, readf64(vbuf, vc + 8) - toy
@@ -38957,31 +39204,42 @@ local function DrawLayer(bd, layer, plist, np, lx0, ly0, ox, oy, force, z, clip_
tile.Image:WritePixelsBuffer(Vector2.zero, TILE_SIZE, buf)
elseif rw > 0 and ry1 > ry0 then
local rh = ry1 - ry0
- local sub = buffer.create(rw * rh * 4)
+ local sk = rw * 1024 + rh -- partial upload buffers are reused (WritePixelsBuffer needs the exact size)
+ local sub = sub_bufs[sk]
+ if not sub then
+ if n_sub_bufs >= 256 then table.clear(sub_bufs); n_sub_bufs = 0 end
+ sub = buffer.create(rw * rh * 4); sub_bufs[sk] = sub; n_sub_bufs += 1
+ end
for y = 0, rh - 1 do buffer.copy(sub, y * rw * 4, buf, ((ry0 + y) * TILE + rx0) * 4, rw * 4) end
tile.Image:WritePixelsBuffer(Vector2.new(rx0, ry0), Vector2.new(rw, rh), sub)
end
PE()
end
- if not tile.Visible then
+ if not tile.Visible and (tile.Deferred or 0) == 0 then -- (a deferred tile that was hidden holds old pixels: stays hidden until drawn)
tile.Visible = true
tile.Label.Visible = true
end
end
PE()
+ return deferred
end
@native
function ImGui_ImplRoblox_RenderDrawData(draw_data)
local bd = ImGui_ImplRoblox_GetBackendData()
PB("ImGui RenderDrawData")
+ bd.FrameT0 = os.clock()
+ bd.FrameTiles = 0
+ bd.RedrawStarted = false -- the first dirty tile of a frame is always redrawn (progress), the rest within the budget
+ bd.Stale = nil
PB("ImGui textures")
if draw_data.Textures ~= nil then
for _, tex in draw_data.Textures:iter() do
if tex.Status ~= ImTextureStatus.OK then
+ -- No redraw needed for WantUpdates: it only writes texture portions which have never been used before
+ -- (new glyphs/rects), so no existing tile samples them. Created textures get a new (never reused) id.
ImGui_ImplRoblox_UpdateTexture(tex)
- bd.ForceRedraw = true -- texture contents changed: tiles sampling it must be redrawn
end
end
end
@@ -39019,20 +39277,27 @@ function ImGui_ImplRoblox_RenderDrawData(draw_data)
local n = 0
local z = 0
+ local any_deferred = false
for _, draw_list in draw_data.CmdLists:iter() do
- -- Unchanged draw list (same vertex bytes, indices, commands, origin and scroll): its layers are already
- -- correct, only restack them. Static windows then cost one byte compare instead of collect + hash.
+ -- Unchanged draw list (same vertices, indices, commands, origin and scroll): its layers are already correct,
+ -- only restack them. Moved by whole pixels (a dragged window): only move its layers. Either way no collect + hash.
PB("ImGui unchanged check")
- local sig = DrawListSignature(draw_list, ox, oy)
local set = bd.Layers[draw_list]
- if sig and set and not force and set.Sig == sig then
+ local same, sdx, sdy = 0, 0, 0
+ if not force then same, sdx, sdy = CompareDrawList(set, draw_list, ox, oy, sw, sh) end
+ if same ~= 0 then
for kind = 1, 3 do
local layer = set[kind]
if layer and layer.Visible then
+ if same == 2 then ShiftLayer(layer, sdx, sdy, bd.Scale) end
z += 1
if layer.Z ~= z then layer.Z = z; layer.Root.ZIndex = z end
end
end
+ if same == 2 then
+ Snapshot(set, draw_list, ox, oy, sw, sh)
+ set.SEX0 += sdx; set.SEY0 += sdy; set.SEX1 += sdx; set.SEY1 += sdy
+ end
set.LastUsed = frame_no
z = PlaceOverlays(bd, draw_list, z, ox, oy)
PE()
@@ -39050,13 +39315,30 @@ function ImGui_ImplRoblox_RenderDrawData(draw_data)
content_x1, content_y1 = min(floor(ccx1 - ox), lim_x1), min(floor(ccy1 - oy), lim_y1)
end
local seen_content = false
+ -- extents of everything clamped by the clip limits below (lim_free: nothing was), for the move fast path
+ local ex0, ey0, ex1, ey1 = math.huge, math.huge, -math.huge, -math.huge
+ local lim_free = true
+ if ccx0 then
+ local a, b, c, d = floor(ccx0 - ox), floor(ccy0 - oy), floor(ccx1 - ox), floor(ccy1 - oy)
+ if a < lim_x0 or b < lim_y0 or c > lim_x1 or d > lim_y1 then lim_free = false end
+ ex0, ey0, ex1, ey1 = min(ex0, a), min(ey0, b), max(ex1, c), max(ey1, d)
+ end
+ local ci = 0
for _, pcmd in draw_list.CmdBuffer:iter() do
+ ci += 1
+ cm_free[ci], cm_kind[ci] = false, 0
if pcmd.UserCallback ~= nil then
pcmd.UserCallback(draw_list, pcmd)
elseif pcmd.ElemCount > 0 then
local clip = pcmd.ClipRect
- local cx0, cy0 = max(floor(clip.x - ox), lim_x0), max(floor(clip.y - oy), lim_y0)
- local cx1, cy1 = min(floor(clip.z - ox), lim_x1), min(floor(clip.w - oy), lim_y1)
+ local rcx0, rcy0, rcx1, rcy1 = floor(clip.x - ox), floor(clip.y - oy), floor(clip.z - ox), floor(clip.w - oy)
+ if rcx0 < lim_x0 or rcy0 < lim_y0 or rcx1 > lim_x1 or rcy1 > lim_y1 then lim_free = false end
+ if rcx0 < ex0 then ex0 = rcx0 end
+ if rcy0 < ey0 then ey0 = rcy0 end
+ if rcx1 > ex1 then ex1 = rcx1 end
+ if rcy1 > ey1 then ey1 = rcy1 end
+ local cx0, cy0 = max(rcx0, lim_x0), max(rcy0, lim_y0)
+ local cx1, cy1 = min(rcx1, lim_x1), min(rcy1, lim_y1)
local kind = 1
if ccx0 and clip.x >= ccx0 and clip.y >= ccy0 and clip.z <= ccx1 and clip.w <= ccy1 then
kind = 2; seen_content = true
@@ -39067,6 +39349,8 @@ function ImGui_ImplRoblox_RenderDrawData(draw_data)
elseif seen_content then
kind = 3
end
+ cm_kind[ci] = kind
+ local cfree, cex0, cey0, cex1, cey1 = cx1 > cx0 and cy1 > cy0, math.huge, math.huge, -math.huge, -math.huge
if cx1 > cx0 and cy1 > cy0 then
local tex_id = pcmd:GetTexID()
local t = bd.Textures[tex_id]
@@ -39112,6 +39396,16 @@ function ImGui_ImplRoblox_RenderDrawData(draw_data)
bx1, by1 = ceil(max(pax, pbx, pcx) - ox) + 1, ceil(max(pay, pby, pcy) - oy) + 1
i = i + 3
end
+ if bx0 < cx0 or by0 < cy0 or bx1 > cx1 or by1 > cy1 then cfree = false end
+ if bx0 < cex0 then cex0 = bx0 end
+ if by0 < cey0 then cey0 = by0 end
+ if bx1 > cex1 then cex1 = bx1 end
+ if by1 > cey1 then cey1 = by1 end
+ if bx0 < ex0 then ex0 = bx0 end
+ if by0 < ey0 then ey0 = by0 end
+ if bx1 > ex1 then ex1 = bx1 end
+ if by1 > ey1 then ey1 = by1 end
+ if bx0 < lim_x0 or by0 < lim_y0 or bx1 > lim_x1 or by1 > lim_y1 then lim_free = false end
if bx0 < cx0 then bx0 = cx0 end
if by0 < cy0 then by0 = cy0 end
if bx1 > cx1 then bx1 = cx1 end
@@ -39125,12 +39419,16 @@ function ImGui_ImplRoblox_RenderDrawData(draw_data)
end
end
end
+ if cex0 > cex1 then cex0, cey0, cex1, cey1 = 0, 0, 0, 0 end -- (no primitives)
+ cm_free[ci] = cfree
+ cm_x0[ci], cm_y0[ci], cm_x1[ci], cm_y1[ci] = cex0, cey0, cex1, cey1
end
end
PE()
-- Split into layer kinds and place each one
set = bd.Layers[draw_list]
+ local list_deferred = false
local nk1, nk2, nk3 = 0, 0, 0
local o1x, o1y, o3x, o3y = math.huge, math.huge, math.huge, math.huge
for p = first, n do
@@ -39149,19 +39447,28 @@ function ImGui_ImplRoblox_RenderDrawData(draw_data)
end
if nk1 > 0 then
z += 1
- DrawLayer(bd, GetLayer(bd, draw_list, 1), plist1, nk1, o1x, o1y, ox, oy, force, z)
+ if DrawLayer(bd, GetLayer(bd, draw_list, 1), plist1, nk1, o1x, o1y, ox, oy, force, z) then list_deferred = true end
elseif set and set[1] then HideLayer(set[1]) end
if nk2 > 0 then
z += 1
- DrawLayer(bd, GetLayer(bd, draw_list, 2), plist2, nk2, floor(draw_list._ScrollOriginX - ox), floor(draw_list._ScrollOriginY - oy), ox, oy, force, z,
- content_x0, content_y0, content_x1, content_y1)
+ if DrawLayer(bd, GetLayer(bd, draw_list, 2), plist2, nk2, floor(draw_list._ScrollOriginX - ox), floor(draw_list._ScrollOriginY - oy), ox, oy, force, z,
+ content_x0, content_y0, content_x1, content_y1) then list_deferred = true end
elseif set and set[2] then HideLayer(set[2]) end
if nk3 > 0 then
z += 1
- DrawLayer(bd, GetLayer(bd, draw_list, 3), plist3, nk3, o3x, o3y, ox, oy, force, z)
+ if DrawLayer(bd, GetLayer(bd, draw_list, 3), plist3, nk3, o3x, o3y, ox, oy, force, z) then list_deferred = true end
elseif set and set[3] then HideLayer(set[3]) end
set = bd.Layers[draw_list]
- if set then set.LastUsed = frame_no; set.Sig = sig end
+ -- (deferred tiles: not "unchanged" next frame even if the draw list is, so they get redrawn)
+ if set then
+ set.LastUsed = frame_no
+ if list_deferred then set.SnapOK = false else
+ Snapshot(set, draw_list, ox, oy, sw, sh)
+ set.SLimFree = lim_free
+ set.SEX0, set.SEY0, set.SEX1, set.SEY1 = ex0, ey0, ex1, ey1
+ end
+ end
+ if list_deferred then any_deferred = true end
z = PlaceOverlays(bd, draw_list, z, ox, oy)
end
@@ -39174,7 +39481,7 @@ function ImGui_ImplRoblox_RenderDrawData(draw_data)
-- Hide layers not drawn this frame; free long unused ones (closed windows, old tooltips)
for draw_list, set in pairs(bd.Layers) do
if set.LastUsed ~= frame_no then
- set.Sig = nil -- hidden now: must go through the full path when shown again
+ set.SnapOK = false -- hidden now: must go through the full path when shown again
for kind = 1, 3 do if set[kind] then HideLayer(set[kind]) end end
if frame_no - set.LastUsed > LAYER_KEEP_FRAMES then
DestroyLayerSet(set)
@@ -39186,6 +39493,8 @@ function ImGui_ImplRoblox_RenderDrawData(draw_data)
-- drop references so old vertex tables can be collected
for k = n + 1, bd.LastPrimCount do pr_t[k], pr_vb[k] = nil, nil end
bd.LastPrimCount = n
+ -- Tiles left for later: if the app doesn't submit a new frame, Heartbeat finishes them from this draw data
+ if any_deferred then bd.Stale = draw_data; bd.StaleHB = bd.HB end
PE()
end
@@ -39211,7 +39520,7 @@ function ImGui_ImplRoblox_Init(parent, render_scale)
local bd = {
Time = os.clock(), Gui = gui, Connections = {},
Textures = {}, TextureCount = 0, MouseX = -1, MouseY = -1,
- Layers = {}, Overlays = {}, OverlayHolders = {}, OverlayShown = {}, FrameNo = 0, Scale = render_scale or 1, TilesActive = false, RenderRate = 60, BusyRenderRate = 0, NextRender = 0, LastPrimCount = 0, ForceRedraw = true,
+ Layers = {}, Overlays = {}, OverlayHolders = {}, OverlayShown = {}, FrameNo = 0, Scale = render_scale or 1, TilesActive = false, RenderRate = 0, BusyRenderRate = 0, RedrawBudget = 0.0015, HB = 0, NextRender = 0, LastPrimCount = 0, ForceRedraw = true,
}
io.BackendPlatformUserData = bd
@@ -39278,7 +39587,10 @@ function ImGui_ImplRoblox_Init(parent, render_scale)
if d and os.clock() + 0.002 >= bd.NextRender then
bd.Pending = nil
ImGui_ImplRoblox_RenderDrawData(d)
+ elseif bd.Stale and bd.HB > bd.StaleHB then -- deferred tiles of a frame not followed by a new one
+ ImGui_ImplRoblox_RenderDrawData(bd.Stale)
end
+ bd.HB += 1
end)
connect(UserInputService.WindowFocused, function() ImGui.GetIO():AddFocusEvent(true) end)
@@ -39341,6 +39653,7 @@ function ImGui_ImplRoblox_NewFrame()
local io = ImGui.GetIO()
local bd = ImGui_ImplRoblox_GetBackendData()
if bd.Pending then bd.Pending = nil; table.clear(bd.Overlays) end -- skipped frame superseded (its draw lists get reset)
+ bd.Stale = nil
local screen = bd.Gui.AbsoluteSize
local w, h = floor(screen.X * bd.Scale), floor(screen.Y * bd.Scale)
@@ -39447,7 +39760,14 @@ function ImGui_ImplRoblox_NewFrame()
PE()
end
---- Max tile redraws per second (default 60); 0 = every frame. imgui logic still runs every frame.
+--- Max milliseconds per frame spent redrawing changed tiles (default 1.5; 0 = no limit). Tiles over the budget keep
+--- their previous pixels and are redrawn on the following frames (each one at most a few frames late), so a big change
+--- (resizing a large window, opening one, scrolling a long list) is spread over frames instead of one long frame.
+function ImGui_ImplRoblox_SetRedrawBudget(ms)
+ ImGui_ImplRoblox_GetBackendData().RedrawBudget = (ms or 0) / 1000
+end
+
+--- Max tile redraws per second (default 0 = every frame, limited by the redraw budget instead). imgui logic still runs every frame.
function ImGui_ImplRoblox_SetRenderRate(hz)
ImGui_ImplRoblox_GetBackendData().RenderRate = hz or 0
end
@@ -39477,6 +39797,7 @@ ImGui_ImplRoblox = {
AddFile = ImGui_ImplRoblox_AddFile,
SetRenderScale = ImGui_ImplRoblox_SetRenderScale,
SetRenderRate = ImGui_ImplRoblox_SetRenderRate,
+ SetRedrawBudget = ImGui_ImplRoblox_SetRedrawBudget,
SetOverlay = ImGui_ImplRoblox_SetOverlay,
SetTextProxy = ImGui_ImplRoblox_SetTextProxy,
CreateTexture = ImGui_ImplRoblox_CreateTexture,
@@ -70559,6 +70880,7 @@ return {
ImGui_ImplRoblox_SetBusyRenderRate = ImGui_ImplRoblox_SetBusyRenderRate,
ImGui_ImplRoblox_SetOverlay = ImGui_ImplRoblox_SetOverlay,
ImGui_ImplRoblox_SetProfiling = ImGui_ImplRoblox_SetProfiling,
+ ImGui_ImplRoblox_SetRedrawBudget = ImGui_ImplRoblox_SetRedrawBudget,
ImGui_ImplRoblox_SetRenderRate = ImGui_ImplRoblox_SetRenderRate,
ImGui_ImplRoblox_SetRenderScale = ImGui_ImplRoblox_SetRenderScale,
ImGui_ImplRoblox_SetTextProxy = ImGui_ImplRoblox_SetTextProxy,
diff --git a/build/imgui_love.linemap.json b/build/imgui_love.linemap.json
index 3ae186a..8b77e0a 100644
--- a/build/imgui_love.linemap.json
+++ b/build/imgui_love.linemap.json
@@ -1 +1 @@
-[[149, "imgui.lua"], [21425, "imgui_demo.lua"], [29843, "imgui_draw.lua"], [35613, "imgui_h.lua"], [38279, "imgui_impl_love.lua"], [38819, "imgui_internal.lua"], [42265, "imgui_tables.lua"], [47260, "imgui_widgets.lua"], [57870, "imstb_rectpack.lua"], [58534, "imstb_textedit.lua"], [60076, "imstb_truetype.lua"], [62864, "imstd_minstdio.lua"]]
\ No newline at end of file
+[[149, "imgui.lua"], [21425, "imgui_demo.lua"], [29843, "imgui_draw.lua"], [35639, "imgui_h.lua"], [38305, "imgui_impl_love.lua"], [38845, "imgui_internal.lua"], [42291, "imgui_tables.lua"], [47286, "imgui_widgets.lua"], [57896, "imstb_rectpack.lua"], [58560, "imstb_textedit.lua"], [60102, "imstb_truetype.lua"], [62890, "imstd_minstdio.lua"]]
\ No newline at end of file
diff --git a/build/imgui_love.lua b/build/imgui_love.lua
index b0a9649..58fe3d1 100644
--- a/build/imgui_love.lua
+++ b/build/imgui_love.lua
@@ -32711,6 +32711,7 @@ end
--- @param wrap_width float
--- @param flags ImDrawTextFlags
--- @return int
+
function ImFontCalcWordWrapPositionEx(font, size, text, pos, text_end, wrap_width, flags)
-- For references, possible wrap point marked with ^
-- "aaa bbb, ccc,ddd. eee fff. ggg!"
@@ -32741,10 +32742,12 @@ function ImFontCalcWordWrapPositionEx(font, size, text, pos, text_end, wrap_widt
local span_width = 0.0
local stop = false
+ local text_is_str = type(text) == "string" -- [Luau] ImStrByte() inlined (once per character)
while s < text_end do
repeat
--- @type unsigned_int
- local c = ImStrByte(text, s)
+ local c
+ if text_is_str then c = string.byte(text, s) else c = text[s] end
local next_s
if c < 0x80 then
next_s = s + 1
@@ -32773,8 +32776,8 @@ function ImFontCalcWordWrapPositionEx(font, size, text, pos, text_end, wrap_widt
-- Classify current character
local curr_type
- if c < 128 then
- curr_type = ImTextClassifierGet(g_CharClassifierIsSeparator_0000_007f, c)
+ if c < 128 then -- [Luau] ImTextClassifierGet() inlined
+ curr_type = bit32.band(bit32.rshift(g_CharClassifierIsSeparator_0000_007f[bit32.rshift(c, 4) + 1], bit32.lshift(bit32.band(c, 15), 1)), 0x03)
elseif (c >= 0x3000 and c < 0x3010) then
curr_type = ImTextClassifierGet(g_CharClassifierIsSeparator_3000_300f, bit32.band(c, 15))
else
@@ -32853,9 +32856,11 @@ local TEXT_SIZE_CACHE_MAX = 4096
function ImFontCalcTextSizeEx(font, size, max_width, wrap_width, text, text_begin, text_end_display, text_end, out_offset, flags)
+ local text_is_str = type(text) == "string"
-- [Luau] cache for the common case (whole string, single pass, no wrap/limit): per baked font, text -> unscaled width, line count.
-- Advances of a baked font never change once loaded, so entries stay valid. ponytail: whole-cache reset at TEXT_SIZE_CACHE_MAX entries, LRU if dynamic text churns it
local cache_key = nil
+ local wrap_cache = nil
if text_begin == 1 and out_offset == nil and wrap_width <= 0.0 and max_width >= FLT_MAX and type(text) == "string"
and (text_end == nil or text_end == #text + 1) and (text_end_display == nil or text_end_display == #text + 1) and (flags == nil or bit32.band(flags, ImDrawTextFlags.StopOnNewLine) == 0) then
local baked = font:GetFontBaked(size)
@@ -32868,6 +32873,23 @@ function ImFontCalcTextSizeEx(font, size, max_width, wrap_width, text, text_begi
return ImVec2(e[1] * (size / baked.Size), e[2] * size), #text + 1
end
cache_key = text
+ elseif text_begin == 1 and out_offset == nil and wrap_width > 0.0 and max_width >= FLT_MAX and text_is_str
+ and (text_end == nil or text_end == #text + 1) and (text_end_display == nil or text_end_display == #text + 1) and (flags == nil or bit32.band(flags, ImDrawTextFlags.StopOnNewLine) == 0) then
+ -- [Luau] same for wrapped text (TextWrapped() measures every line every frame): exact results per (size, wrap width, text)
+ local baked = font:GetFontBaked(size)
+ local cache = baked._TextSizeCache
+ if cache == nil or baked._TextSizeCacheN >= TEXT_SIZE_CACHE_MAX then
+ cache = {}; baked._TextSizeCache = cache; baked._TextSizeCacheN = 0
+ end
+ local by_size = cache[size]
+ if by_size == nil then by_size = {}; cache[size] = by_size end
+ local by_wrap = by_size[wrap_width]
+ if by_wrap == nil then by_wrap = {}; by_size[wrap_width] = by_wrap end
+ local e = by_wrap[text]
+ if e then
+ return ImVec2(e[1], e[2]), e[3]
+ end
+ wrap_cache = by_wrap
end
if not text_end then
text_end = #text + 1 -- FIXME-OPT: Need to avoid this.
@@ -32915,7 +32937,8 @@ function ImFontCalcTextSizeEx(font, size, max_width, wrap_width, text, text_begi
-- Decode and advance source
local prev_s = s
- local c = ImStrByte(text, s)
+ local c
+ if text_is_str then c = string.byte(text, s) else c = text[s] end -- [Luau] ImStrByte() inlined
if c < 0x80 then
s = s + 1
else
@@ -32975,6 +32998,9 @@ function ImFontCalcTextSizeEx(font, size, max_width, wrap_width, text, text_begi
if cache_key then
baked._TextSizeCache[cache_key] = { text_size.x / scale, text_size.y / line_height }
baked._TextSizeCacheN = baked._TextSizeCacheN + 1
+ elseif wrap_cache then
+ wrap_cache[text] = { text_size.x, text_size.y, out_remaining }
+ baked._TextSizeCacheN = baked._TextSizeCacheN + 1
end
return text_size, out_remaining
diff --git a/examples/example_roblox/main.client.luau b/examples/example_roblox/main.client.luau
index 004bfbe..a664a48 100755
--- a/examples/example_roblox/main.client.luau
+++ b/examples/example_roblox/main.client.luau
@@ -17,7 +17,7 @@ setfenv(1, setmetatable({}, {
local MainScale = 1.0
local RenderScale = 1.0
-local RenderRate = 60
+local RenderRate = 0 -- 0 = every frame (tile redraws are limited by the redraw budget instead)
local ThumbnailSize = 420
local FontSize = 13.0
local FontNames = { "Cousine-Regular", "DroidSans", "Karla-Regular", "ProggyTiny", "Roboto-Medium" }
diff --git a/imgui_draw.lua b/imgui_draw.lua
index 9176703..fc73925 100644
--- a/imgui_draw.lua
+++ b/imgui_draw.lua
@@ -2869,6 +2869,7 @@ end
--- @param wrap_width float
--- @param flags ImDrawTextFlags
--- @return int
+@native
function ImFontCalcWordWrapPositionEx(font, size, text, pos, text_end, wrap_width, flags)
-- For references, possible wrap point marked with ^
-- "aaa bbb, ccc,ddd. eee fff. ggg!"
@@ -2899,10 +2900,12 @@ function ImFontCalcWordWrapPositionEx(font, size, text, pos, text_end, wrap_widt
local span_width = 0.0
local stop = false
+ local text_is_str = type(text) == "string" -- [Luau] ImStrByte() inlined (once per character)
while s < text_end do
repeat
--- @type unsigned_int
- local c = ImStrByte(text, s)
+ local c
+ if text_is_str then c = string.byte(text, s) else c = text[s] end
local next_s
if c < 0x80 then
next_s = s + 1
@@ -2931,8 +2934,8 @@ function ImFontCalcWordWrapPositionEx(font, size, text, pos, text_end, wrap_widt
-- Classify current character
local curr_type
- if c < 128 then
- curr_type = ImTextClassifierGet(g_CharClassifierIsSeparator_0000_007f, c)
+ if c < 128 then -- [Luau] ImTextClassifierGet() inlined
+ curr_type = bit32.band(bit32.rshift(g_CharClassifierIsSeparator_0000_007f[bit32.rshift(c, 4) + 1], bit32.lshift(bit32.band(c, 15), 1)), 0x03)
elseif (c >= 0x3000 and c < 0x3010) then
curr_type = ImTextClassifierGet(g_CharClassifierIsSeparator_3000_300f, bit32.band(c, 15))
else
@@ -3011,9 +3014,11 @@ local TEXT_SIZE_CACHE_MAX = 4096
@native
function ImFontCalcTextSizeEx(font, size, max_width, wrap_width, text, text_begin, text_end_display, text_end, out_offset, flags)
+ local text_is_str = type(text) == "string"
-- [Luau] cache for the common case (whole string, single pass, no wrap/limit): per baked font, text -> unscaled width, line count.
-- Advances of a baked font never change once loaded, so entries stay valid. ponytail: whole-cache reset at TEXT_SIZE_CACHE_MAX entries, LRU if dynamic text churns it
local cache_key = nil
+ local wrap_cache = nil
if text_begin == 1 and out_offset == nil and wrap_width <= 0.0 and max_width >= FLT_MAX and type(text) == "string"
and (text_end == nil or text_end == #text + 1) and (text_end_display == nil or text_end_display == #text + 1) and (flags == nil or bit32.band(flags, ImDrawTextFlags.StopOnNewLine) == 0) then
local baked = font:GetFontBaked(size)
@@ -3026,6 +3031,23 @@ function ImFontCalcTextSizeEx(font, size, max_width, wrap_width, text, text_begi
return ImVec2(e[1] * (size / baked.Size), e[2] * size), #text + 1
end
cache_key = text
+ elseif text_begin == 1 and out_offset == nil and wrap_width > 0.0 and max_width >= FLT_MAX and text_is_str
+ and (text_end == nil or text_end == #text + 1) and (text_end_display == nil or text_end_display == #text + 1) and (flags == nil or bit32.band(flags, ImDrawTextFlags.StopOnNewLine) == 0) then
+ -- [Luau] same for wrapped text (TextWrapped() measures every line every frame): exact results per (size, wrap width, text)
+ local baked = font:GetFontBaked(size)
+ local cache = baked._TextSizeCache
+ if cache == nil or baked._TextSizeCacheN >= TEXT_SIZE_CACHE_MAX then
+ cache = {}; baked._TextSizeCache = cache; baked._TextSizeCacheN = 0
+ end
+ local by_size = cache[size]
+ if by_size == nil then by_size = {}; cache[size] = by_size end
+ local by_wrap = by_size[wrap_width]
+ if by_wrap == nil then by_wrap = {}; by_size[wrap_width] = by_wrap end
+ local e = by_wrap[text]
+ if e then
+ return ImVec2(e[1], e[2]), e[3]
+ end
+ wrap_cache = by_wrap
end
if not text_end then
text_end = #text + 1 -- FIXME-OPT: Need to avoid this.
@@ -3073,7 +3095,8 @@ function ImFontCalcTextSizeEx(font, size, max_width, wrap_width, text, text_begi
-- Decode and advance source
local prev_s = s
- local c = ImStrByte(text, s)
+ local c
+ if text_is_str then c = string.byte(text, s) else c = text[s] end -- [Luau] ImStrByte() inlined
if c < 0x80 then
s = s + 1
else
@@ -3133,6 +3156,9 @@ function ImFontCalcTextSizeEx(font, size, max_width, wrap_width, text, text_begi
if cache_key then
baked._TextSizeCache[cache_key] = { text_size.x / scale, text_size.y / line_height }
baked._TextSizeCacheN = baked._TextSizeCacheN + 1
+ elseif wrap_cache then
+ wrap_cache[text] = { text_size.x, text_size.y, out_remaining }
+ baked._TextSizeCacheN = baked._TextSizeCacheN + 1
end
return text_size, out_remaining
diff --git a/tools/test/run.py b/tools/test/run.py
index 3b76794..5d5a35f 100644
--- a/tools/test/run.py
+++ b/tools/test/run.py
@@ -68,7 +68,7 @@ def main():
ap.add_argument("--quiet", action="store_true", help="no random input (scripted tests drive io themselves)")
args = ap.parse_args()
- code, out = run(args, "")
+ code, out = run(args, os.environ.get("RUN_FLAGS", ""))
print(out if os.environ.get("FULL_OUTPUT") else out[-4000:])
sys.exit(code)
diff --git a/tools/test/scripts/t_pixels.luau b/tools/test/scripts/t_pixels.luau
new file mode 100644
index 0000000..90ac023
--- /dev/null
+++ b/tools/test/scripts/t_pixels.luau
@@ -0,0 +1,133 @@
+-- Backend pixel oracle: composites the ScreenGui (like t_composite) at checkpoints and prints "PIX ".
+-- Phases: resize (bottom-right, then top-left), move, scroll, hover, typing, new font sizes (glyph baking), colour drag.
+-- Each phase ends with a few still frames, then a checkpoint. Run with RUN_FLAGS="ARG_FORCE=true" for a full redraw
+-- every frame: incremental output must match it exactly (and match the previous backend version).
+local G = require(game:GetService("ReplicatedStorage"):WaitForChild("ImGui"))
+local ImGui, ImVec2, ImVec4 = G.ImGui, G.ImVec2, G.ImVec4
+ImGui.CreateContext()
+G.ImGui_ImplRoblox.Init()
+G.ImGui_ImplRoblox.SetRenderRate(0)
+if ARG_TILELIMIT then ImGui.GetIO().BackendPlatformUserData.TileLimit = ARG_TILELIMIT end
+local io = ImGui.GetIO()
+local W, H = 1280, 760
+local col = ImVec4(0.4, 0.7, 0.2, 0.5)
+local buf = ImGui.NewTextBuffer("type here", 128)
+local slider = 0.3
+
+local function composite(gui)
+ local screen = buffer.create(W * H * 3)
+ local function walk(inst, ox, oy, kx0, ky0, kx1, ky1)
+ local kids = inst:GetChildren()
+ table.sort(kids, function(a, b) return (a.ZIndex or 1) < (b.ZIndex or 1) end)
+ for _, c in ipairs(kids) do
+ if c.Visible ~= false then
+ local x, y = ox + (c.Position and c.Position.X or 0), oy + (c.Position and c.Position.Y or 0)
+ local cx0, cy0, cx1, cy1 = kx0, ky0, kx1, ky1
+ if c.ClipsDescendants then
+ cx0, cy0 = math.max(cx0, x), math.max(cy0, y)
+ cx1, cy1 = math.min(cx1, x + c.Size.X), math.min(cy1, y + c.Size.Y)
+ end
+ if c.ClassName == "ImageLabel" then
+ local img = c.ImageContent.Object
+ local px, sz = img.Pixels, img.Size.X
+ for ty = 0, sz - 1 do
+ local sy = y + ty
+ if sy >= cy0 and sy < cy1 then
+ for tx = 0, sz - 1 do
+ local sx = x + tx
+ if sx >= cx0 and sx < cx1 then
+ local o = (ty * sz + tx) * 4
+ local a = buffer.readu8(px, o + 3)
+ if a > 0 then
+ local d = (sy * W + sx) * 3
+ local fa = a / 255
+ for ch = 0, 2 do
+ local v = buffer.readu8(px, o + ch) * fa + buffer.readu8(screen, d + ch) * (1 - fa)
+ buffer.writeu8(screen, d + ch, math.floor(v + 0.5))
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ walk(c, x, y, cx0, cy0, cx1, cy1)
+ end
+ end
+ end
+ walk(gui, 0, 0, 0, 0, W, H)
+ local h = 0
+ for i = 0, W * H * 3 - 4, 4 do h = (h * 31 + buffer.readu32(screen, i)) % 2147483647 end
+ return h
+end
+
+-- phase list: name, frames of action (then 6 still frames + checkpoint)
+local PHASES = { { "start", 4 }, { "resize_br", 24 }, { "resize_tl", 24 }, { "move", 24 }, { "scroll", 30 }, { "hover", 30 },
+ { "type", 24 }, { "fonts", 24 }, { "color", 20 } }
+local STILL = ARG_STILL or (ARG_TILELIMIT and 40 or 6) -- tile limit: deferred tiles need more frames to settle
+local PT = {}
+local pi, pf = 1, 0
+local pos, size, scroll, fsize = ImVec2(40, 40), ImVec2(460, 520), 0, 13
+local frame = 0
+local typing = "Hello, glyphs! 0123 xyz"
+
+game:GetService("RunService").Stepped:Connect(function()
+ local ph = PHASES[pi]
+ if not ph then return end
+ frame += 1; pf += 1
+ local name, n = ph[1], ph[2]
+ local act = pf <= n
+ G.ImGui_ImplRoblox.NewFrame()
+ io.DeltaTime = 1 / 60
+ io:AddMousePosEvent(1200, 740)
+ if act then
+ if name == "resize_br" then size = ImVec2(460 + pf * 7, 520 + pf * 5)
+ elseif name == "resize_tl" then pos = ImVec2(40 + pf * 3, 40 + pf * 2); size = ImVec2(size.x - 3, size.y - 2)
+ elseif name == "move" then pos = ImVec2(pos.x + 5, pos.y + 3)
+ elseif name == "scroll" then scroll += 9
+ elseif name == "hover" then io:AddMousePosEvent(pos.x + 30 + (pf * 17) % 300, pos.y + 40 + (pf * 23) % 400)
+ elseif name == "type" then
+ if pf == 1 then io:AddMousePosEvent(pos.x + 120, pos.y + 37); io:AddMouseButtonEvent(0, true)
+ elseif pf == 2 then io:AddMouseButtonEvent(0, false)
+ else io:AddInputCharacter(string.byte(typing, (pf - 3) % #typing + 1)) end
+ elseif name == "fonts" then fsize = 13 + pf * 0.75
+ elseif name == "color" then
+ local p = ImVec2(700 + pf * 4, 120 + pf * 3)
+ io:AddMousePosEvent(p.x, p.y); io:AddMouseButtonEvent(0, pf < n)
+ end
+ end
+ ImGui.NewFrame()
+ ImGui.SetNextWindowPos(pos, G.ImGuiCond.Always); ImGui.SetNextWindowSize(size, G.ImGuiCond.Always)
+ ImGui.Begin("Stress")
+ ImGui.InputText("##in", buf, 128)
+ if name == "scroll" or scroll > 0 then ImGui.SetScrollY(scroll) end
+ for i = 1, 12 do ImGui.Button("Button " .. i); ImGui.SameLine(); slider = ImGui.SliderFloat("##s" .. i, slider, 0, 1) end
+ if ImGui.BeginTable("tbl", 4, bit32.bor(G.ImGuiTableFlags.Borders, G.ImGuiTableFlags.RowBg)) then
+ for r = 1, 30 do ImGui.TableNextRow(); for c = 1, 4 do ImGui.TableNextColumn(); ImGui.Text("r%d c%d", r, c) end end
+ ImGui.EndTable()
+ end
+ ImGui.PushFont(nil, fsize); ImGui.Text("Font size %.2f AaBbCc", fsize); ImGui.PopFont()
+ for i = 1, 30 do ImGui.TextWrapped("Line %d: the quick brown fox jumps over the lazy dog.", i) end
+ ImGui.End()
+ ImGui.SetNextWindowPos(ImVec2(660, 60), G.ImGuiCond.Always); ImGui.SetNextWindowSize(ImVec2(500, 620), G.ImGuiCond.Always)
+ ImGui.Begin("Pickers")
+ ImGui.ColorPicker4("bar", col, G.ImGuiColorEditFlags.PickerHueBar)
+ ImGui.End()
+ ImGui.Render()
+ local clk = os.realclock or os.clock
+ local t0 = clk()
+ G.ImGui_ImplRoblox.RenderDrawData(ImGui.GetDrawData())
+ if act then PT[name] = (PT[name] or 0) + (clk() - t0) * 1000 end
+ if pf == n then -- mid-action (not settled): only exact without a redraw budget
+ print("PIXA", name, frame, composite(game:GetService("Players").LocalPlayer.PlayerGui:FindFirstChild("ImGui")))
+ end
+ if pf == n + STILL then
+ local gui = game:GetService("Players").LocalPlayer.PlayerGui:FindFirstChild("ImGui")
+ print("PIX", name, frame, composite(gui))
+ pi += 1; pf = 0
+ if not PHASES[pi] then
+ for _, q in PHASES do print(string.format("PIXT %-10s %7.2f ms/frame", q[1], PT[q[1]] / q[2])) end
+ print("PIXELS DONE")
+ end
+ end
+end)