diff --git a/Runtime/DisplayXRNative.cs b/Runtime/DisplayXRNative.cs
index e8bec78..cd05f5a 100644
--- a/Runtime/DisplayXRNative.cs
+++ b/Runtime/DisplayXRNative.cs
@@ -672,6 +672,25 @@ public static extern void displayxr_set_overlay_hit_mask(
public static extern void displayxr_set_overlay_surround_rect(
int x, int y, int w, int h);
+ ///
+ /// (#131) Per-pixel variant of displayxr_set_overlay_surround_rect:
+ /// register the EXACT shape of a 2D surround element (e.g. a comic
+ /// bubble with a triangular tail) as an alpha mask (mask_w*mask_h
+ /// bytes, non-zero = opaque/catch) mapped over the dst rect (overlay
+ /// client px, top-left). RLE-unioned into the SetWindowRgn region each
+ /// frame, so the element catches clicks while the empty area beside it
+ /// (e.g. the corners next to the tail) keeps routing to the desktop —
+ /// which a single bounding rect can't express. The surround is flat
+ /// post-weave 2D, so the caller rasterizes the mask directly (no
+ /// disparity / per-view math). The plugin copies the bytes. Pass
+ /// mask = IntPtr.Zero or any dim <= 0 to clear. Transparent overlay
+ /// (hooked) path only.
+ ///
+ [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)]
+ public static extern void displayxr_set_overlay_surround_mask(
+ IntPtr mask, int mask_w, int mask_h,
+ int dst_x, int dst_y, int dst_w, int dst_h);
+
///
/// Read cursor position (overlay-client coords, top-left origin) and
/// mouse button state. Designed for transparent overlay mode where
diff --git a/Runtime/Plugins/Windows/x64/displayxr_unity.dll b/Runtime/Plugins/Windows/x64/displayxr_unity.dll
index 1bd5598..e6db2b7 100755
Binary files a/Runtime/Plugins/Windows/x64/displayxr_unity.dll and b/Runtime/Plugins/Windows/x64/displayxr_unity.dll differ
diff --git a/native~/displayxr_hooks.h b/native~/displayxr_hooks.h
index d09699d..c88bece 100644
--- a/native~/displayxr_hooks.h
+++ b/native~/displayxr_hooks.h
@@ -269,6 +269,23 @@ DISPLAYXR_EXPORT void displayxr_set_overlay_hit_mask(const uint8_t *mask,
DISPLAYXR_EXPORT void displayxr_set_overlay_surround_rect(int x, int y,
int w, int h);
+/// (#131) Per-pixel variant of displayxr_set_overlay_surround_rect: register the
+/// EXACT shape of a 2D surround element (e.g. a comic bubble with a triangular
+/// tail) as an alpha mask (mask_w*mask_h bytes, non-zero = opaque/catch), mapped
+/// over the dst rect (overlay client px, top-left). It is RLE'd and UNION-ed into
+/// the SetWindowRgn region built by displayxr_set_overlay_hit_mask each frame, so
+/// the element catches clicks while the empty area beside/around it (including the
+/// corners next to a triangular tail) keeps routing past to the desktop — which a
+/// single bounding rect cannot express. The surround is flat post-weave 2D, so
+/// the caller rasterizes the mask directly (no disparity / per-view math). The
+/// plugin copies the bytes. Pass mask=NULL or any dim <=0 to clear. Coexists with
+/// the rect API (both are unioned in); callers using the mask should clear the
+/// rect. Takes effect on the next hit-mask update.
+DISPLAYXR_EXPORT void displayxr_set_overlay_surround_mask(const uint8_t *mask,
+ int mask_w, int mask_h,
+ int dst_x, int dst_y,
+ int dst_w, int dst_h);
+
/// (issue #57) Returns 1 if the OS foreground window belongs to our process,
/// 0 otherwise. Use to gate input handlers (WASD etc.) that should be
/// inactive when the user has clicked through the overlay to another app.
diff --git a/native~/displayxr_win32.c b/native~/displayxr_win32.c
index d81c63a..a7724d6 100644
--- a/native~/displayxr_win32.c
+++ b/native~/displayxr_win32.c
@@ -120,9 +120,28 @@ static int s_hit_mask_active = 0;
// catch clicks even though it sits outside the 3D silhouette. UNION-ed into the
// SetWindowRgn region built by displayxr_set_overlay_hit_mask each frame. Invalid
// by default (no bubble) so empty surround keeps routing clicks to the desktop.
+//
+// The rect is a coarse bounding box; for a non-rectangular surround element (a
+// comic bubble with a triangular tail) it would catch clicks in the empty corners
+// beside the shape. The surround MASK below supersedes it: a per-pixel alpha of
+// the actual shape, RLE'd into the region exactly like the tiger silhouette — but
+// flat 2D (no disparity / no per-view union), so the caller can rasterize it on
+// the CPU. When a mask is set the caller should clear the rect (both are unioned
+// in if both are valid). The rect path stays for older callers / compat.
static int s_surround_rect_valid = 0;
static RECT s_surround_rect = {0, 0, 0, 0};
+// (#131) Per-pixel surround shape mask (non-zero = opaque/catch). Owned copy,
+// mapped over s_surround_mask_dst (overlay client px, top-left) when the region is
+// rebuilt. NULL/invalid by default. Unlike the tiger hit-mask (which maps into the
+// canvas sub-rect and is owned by the silhouette each frame), this maps wherever
+// the caller places its 2D element in the full surround and is unioned in.
+static uint8_t *s_surround_mask = NULL;
+static int s_surround_mask_w = 0;
+static int s_surround_mask_h = 0;
+static RECT s_surround_mask_dst = {0, 0, 0, 0};
+static int s_surround_mask_valid = 0;
+
// ============================================================================
// Shell mode detection
// ============================================================================
@@ -1352,6 +1371,46 @@ displayxr_set_overlay_hit_mask(const uint8_t *mask, int mask_w, int mask_h,
rects[n++] = s_surround_rect;
}
+ // (#131) Union the per-pixel surround mask (e.g. the exact rounded-bubble +
+ // triangular-tail shape), RLE'd into rects over its dst rect with the same
+ // outward edge rounding as the tiger silhouette above. This is what makes the
+ // empty area BESIDE a non-rectangular tail route clicks through — a single
+ // bounding rect can't express that. Flat 2D: the caller hands us the shape
+ // directly (no view/disparity math, since the surround is post-weave).
+ if (s_surround_mask_valid && s_surround_mask != NULL) {
+ LONG sdx = s_surround_mask_dst.left;
+ LONG sdy = s_surround_mask_dst.top;
+ LONG sdw = s_surround_mask_dst.right - s_surround_mask_dst.left;
+ LONG sdh = s_surround_mask_dst.bottom - s_surround_mask_dst.top;
+ int smw = s_surround_mask_w, smh = s_surround_mask_h;
+ for (int my = 0; my < smh; my++) {
+ const uint8_t *row = s_surround_mask + (size_t)my * (size_t)smw;
+ int top = (int)(sdy + (LONGLONG)my * sdh / smh);
+ int bottom = (int)(sdy + ((LONGLONG)(my + 1) * sdh + smh - 1) / smh);
+ int mx = 0;
+ while (mx < smw) {
+ while (mx < smw && row[mx] == 0) mx++;
+ if (mx >= smw) break;
+ int x0 = mx;
+ while (mx < smw && row[mx] != 0) mx++;
+ int x1 = mx;
+ if (n >= cap) {
+ int new_cap = cap * 2;
+ RECT *nr = (RECT *)realloc(rects,
+ (size_t)new_cap * sizeof(RECT));
+ if (nr == NULL) { free(rects); return; }
+ rects = nr;
+ cap = new_cap;
+ }
+ rects[n].left = (LONG)(sdx + (LONGLONG)x0 * sdw / smw);
+ rects[n].top = (LONG)top;
+ rects[n].right = (LONG)(sdx + ((LONGLONG)x1 * sdw + smw - 1) / smw);
+ rects[n].bottom = (LONG)bottom;
+ n++;
+ }
+ }
+ }
+
HRGN rgn = NULL;
if (n == 0) {
// Empty silhouette: 0x0 rect = nothing-catches region.
@@ -1430,6 +1489,47 @@ displayxr_set_overlay_surround_rect(int x, int y, int w, int h)
displayxr_log("[DisplayXR] surround_rect: (%d,%d) %dx%d\n", x, y, w, h);
}
+void
+displayxr_set_overlay_surround_mask(const uint8_t *mask, int mask_w, int mask_h,
+ int dst_x, int dst_y, int dst_w, int dst_h)
+{
+ // (#131) Store a per-pixel surround shape mask (non-zero = opaque/catch),
+ // mapped over [dst_x,dst_y,dst_w,dst_h] in overlay client px when the window
+ // region is rebuilt by displayxr_set_overlay_hit_mask. Owned copy so the
+ // caller's buffer need not outlive the call. NULL/empty clears it.
+ if (mask == NULL || mask_w <= 0 || mask_h <= 0 || dst_w <= 0 || dst_h <= 0) {
+ if (s_surround_mask_valid)
+ displayxr_log("[DisplayXR] surround_mask: cleared\n");
+ free(s_surround_mask);
+ s_surround_mask = NULL;
+ s_surround_mask_w = 0;
+ s_surround_mask_h = 0;
+ s_surround_mask_valid = 0;
+ return;
+ }
+
+ size_t bytes = (size_t)mask_w * (size_t)mask_h;
+ uint8_t *copy = (uint8_t *)malloc(bytes);
+ if (copy == NULL) {
+ displayxr_log("[DisplayXR] surround_mask: alloc failed (%dx%d)\n",
+ mask_w, mask_h);
+ return;
+ }
+ memcpy(copy, mask, bytes);
+
+ free(s_surround_mask);
+ s_surround_mask = copy;
+ s_surround_mask_w = mask_w;
+ s_surround_mask_h = mask_h;
+ s_surround_mask_dst.left = dst_x;
+ s_surround_mask_dst.top = dst_y;
+ s_surround_mask_dst.right = dst_x + dst_w;
+ s_surround_mask_dst.bottom = dst_y + dst_h;
+ s_surround_mask_valid = 1;
+ displayxr_log("[DisplayXR] surround_mask: %dx%d -> dst (%d,%d) %dx%d\n",
+ mask_w, mask_h, dst_x, dst_y, dst_w, dst_h);
+}
+
void
displayxr_get_overlay_size(int *width, int *height)
{