perf(stage-tamagotchi): added radius region for detecting Fade on Hover, preventing flickering
This commit is contained in:
@@ -45,7 +45,11 @@ const { isOutside: isOutsideWindow } = useElectronMouseInWindow()
|
||||
const { isOutside } = useElectronMouseInElement(controlsIslandRef)
|
||||
const isOutsideFor250Ms = refDebounced(isOutside, 250)
|
||||
const { x: relativeMouseX, y: relativeMouseY } = useElectronRelativeMouse()
|
||||
const isTransparent = useCanvasPixelIsTransparentAtPoint(stageCanvas, relativeMouseX, relativeMouseY)
|
||||
// NOTICE: In real-world use cases of Fade on Hover feature, the cursor may move around the edge of the
|
||||
// model rapidly, causing flickering effects when checking pixel transparency strictly.
|
||||
// Here we use `regionRadius` to help to detect with look-ahead strategy to relax the pixel accurate
|
||||
// check / detection on hovered underlying canvas pixel, therefore inaccurate mouse movements won't cause flickering.
|
||||
const isTransparent = useCanvasPixelIsTransparentAtPoint(stageCanvas, relativeMouseX, relativeMouseY, { regionRadius: 25 })
|
||||
const { isNearAnyBorder: isAroundWindowBorder } = useElectronMouseAroundWindowBorder({ threshold: 30 })
|
||||
const isAroundWindowBorderFor250Ms = refDebounced(isAroundWindowBorder, 250)
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
"culori": "^4.0.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"es-toolkit": "catalog:",
|
||||
"gpuu": "^1.0.6",
|
||||
"jszip": "^3.10.1",
|
||||
"localforage": "^1.10.0",
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isCanvasRegionTransparent } from './canvas-alpha'
|
||||
|
||||
interface GlMockOptions {
|
||||
hotPixel?: { x: number, y: number, alpha?: number }
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal WebGL context mock that fills readPixels output with transparent pixels,
|
||||
* except for an optional single hot pixel where alpha is set. Coordinates are in
|
||||
* drawing buffer space, matching readPixels inputs.
|
||||
*/
|
||||
function createGlMock(options: GlMockOptions = {}) {
|
||||
const drawingBufferWidth = options.width ?? 100
|
||||
const drawingBufferHeight = options.height ?? 100
|
||||
const hotPixel = options.hotPixel
|
||||
|
||||
const gl = {
|
||||
drawingBufferWidth,
|
||||
drawingBufferHeight,
|
||||
readPixels: (
|
||||
startX: number,
|
||||
startY: number,
|
||||
readWidth: number,
|
||||
readHeight: number,
|
||||
_format: number,
|
||||
_type: number,
|
||||
data: Uint8Array,
|
||||
) => {
|
||||
data.fill(0)
|
||||
|
||||
if (!hotPixel)
|
||||
return
|
||||
|
||||
const { x, y, alpha = 255 } = hotPixel
|
||||
const withinX = x >= startX && x < startX + readWidth
|
||||
const withinY = y >= startY && y < startY + readHeight
|
||||
if (!withinX || !withinY)
|
||||
return
|
||||
|
||||
const relX = x - startX
|
||||
const relY = y - startY
|
||||
const index = (relY * readWidth + relX) * 4 + 3
|
||||
data[index] = alpha
|
||||
},
|
||||
}
|
||||
|
||||
return gl as unknown as WebGLRenderingContext
|
||||
}
|
||||
|
||||
describe('isCanvasRegionTransparent', () => {
|
||||
it('returns true when cursor is outside canvas bounds', () => {
|
||||
const gl = createGlMock()
|
||||
|
||||
const result = isCanvasRegionTransparent({
|
||||
gl,
|
||||
clientX: 150,
|
||||
clientY: 150,
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
radius: 10,
|
||||
threshold: 10,
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when an opaque pixel is inside the circular region', () => {
|
||||
const gl = createGlMock({ hotPixel: { x: 50, y: 49, alpha: 255 } })
|
||||
|
||||
const result = isCanvasRegionTransparent({
|
||||
gl,
|
||||
clientX: 50,
|
||||
clientY: 50,
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
radius: 10,
|
||||
threshold: 10,
|
||||
})
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores opaque pixels outside the circular region but inside read bounds', () => {
|
||||
const gl = createGlMock({ hotPixel: { x: 80, y: 80, alpha: 255 } })
|
||||
|
||||
const result = isCanvasRegionTransparent({
|
||||
gl,
|
||||
clientX: 50,
|
||||
clientY: 50,
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
radius: 5,
|
||||
threshold: 10,
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,97 @@
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue'
|
||||
|
||||
import { toRef, unrefElement, useElementBounding } from '@vueuse/core'
|
||||
import { clamp } from 'es-toolkit/math'
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface CircleHitTestInput {
|
||||
gl: WebGL2RenderingContext | WebGLRenderingContext
|
||||
clientX: number
|
||||
clientY: number
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
radius: number
|
||||
threshold: number
|
||||
}
|
||||
|
||||
export function isCanvasRegionTransparent({
|
||||
gl,
|
||||
clientX,
|
||||
clientY,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
radius,
|
||||
threshold,
|
||||
}: CircleHitTestInput) {
|
||||
if (!width || !height)
|
||||
return true
|
||||
|
||||
if (gl.drawingBufferWidth <= 0 || gl.drawingBufferHeight <= 0)
|
||||
return true
|
||||
|
||||
const xIn = clientX - left
|
||||
const yIn = clientY - top
|
||||
const inCanvas = xIn >= 0 && yIn >= 0 && xIn < width && yIn < height
|
||||
if (!inCanvas)
|
||||
return true
|
||||
|
||||
const scaleX = gl.drawingBufferWidth / width
|
||||
const scaleY = gl.drawingBufferHeight / height
|
||||
if (!Number.isFinite(scaleX) || !Number.isFinite(scaleY))
|
||||
return true
|
||||
|
||||
// Translate client-space coords into WebGL buffer space (respecting DPI scaling and flipped Y),
|
||||
// then read a bounding box that fully contains the desired radius circle. Later we re-check
|
||||
// the circle constraint in CPU land to avoid missing hits at the edges.
|
||||
const centerX = Math.floor(xIn * scaleX)
|
||||
const centerY = Math.floor(gl.drawingBufferHeight - 1 - yIn * scaleY)
|
||||
|
||||
const radiusX = Math.ceil(radius * scaleX)
|
||||
const radiusY = Math.ceil(radius * scaleY)
|
||||
|
||||
const startX = clamp(centerX - radiusX, 0, gl.drawingBufferWidth - 1)
|
||||
const endX = clamp(centerX + radiusX, 0, gl.drawingBufferWidth - 1)
|
||||
const startY = clamp(centerY - radiusY, 0, gl.drawingBufferHeight - 1)
|
||||
const endY = clamp(centerY + radiusY, 0, gl.drawingBufferHeight - 1)
|
||||
|
||||
const readWidth = endX - startX + 1
|
||||
const readHeight = endY - startY + 1
|
||||
const data = new Uint8Array(readWidth * readHeight * 4)
|
||||
|
||||
try {
|
||||
gl.readPixels(startX, startY, readWidth, readHeight, gl.RGBA, gl.UNSIGNED_BYTE, data)
|
||||
}
|
||||
catch {
|
||||
return true
|
||||
}
|
||||
|
||||
const radiusSq = radius * radius
|
||||
|
||||
for (let y = 0; y < readHeight; y += 1) {
|
||||
const gy = startY + y
|
||||
const dy = (gy - centerY) / scaleY
|
||||
const dySq = dy * dy
|
||||
|
||||
for (let x = 0; x < readWidth; x += 1) {
|
||||
const gx = startX + x
|
||||
const dx = (gx - centerX) / scaleX
|
||||
if (dx * dx + dySq > radiusSq)
|
||||
continue
|
||||
|
||||
const index = (y * readWidth + x) * 4
|
||||
const alpha = data[index + 3]
|
||||
if (alpha >= threshold)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function useCanvasPixelAtPoint(
|
||||
canvas: MaybeRefOrGetter<HTMLCanvasElement | undefined>,
|
||||
pointX: MaybeRefOrGetter<number>,
|
||||
@@ -72,8 +161,44 @@ export function useCanvasPixelIsTransparentAtPoint(
|
||||
canvas: MaybeRefOrGetter<HTMLCanvasElement | undefined>,
|
||||
pointX: MaybeRefOrGetter<number>,
|
||||
pointY: MaybeRefOrGetter<number>,
|
||||
threshold = 10,
|
||||
optionsOrThreshold: number | { threshold?: number, regionRadius?: number } = 10,
|
||||
): Ref<boolean> {
|
||||
const { pixel } = useCanvasPixelAtPoint(canvas, pointX, pointY)
|
||||
return useCanvasPixelIsTransparent(pixel, threshold)
|
||||
const options = typeof optionsOrThreshold === 'number'
|
||||
? { threshold: optionsOrThreshold, regionRadius: 0 }
|
||||
: optionsOrThreshold
|
||||
|
||||
const threshold = options?.threshold ?? 10
|
||||
const radius = Math.max(0, options?.regionRadius ?? 0)
|
||||
|
||||
if (radius === 0) {
|
||||
const { pixel } = useCanvasPixelAtPoint(canvas, pointX, pointY)
|
||||
return useCanvasPixelIsTransparent(pixel, threshold)
|
||||
}
|
||||
|
||||
const canvasRef = toRef(canvas)
|
||||
const xRef = toRef(pointX)
|
||||
const yRef = toRef(pointY)
|
||||
const { left, top, width, height } = useElementBounding(canvasRef)
|
||||
|
||||
return computed(() => {
|
||||
const el = unrefElement(canvasRef)
|
||||
if (!el)
|
||||
return true
|
||||
|
||||
const gl = (el.getContext('webgl2') || el.getContext('webgl')) as WebGL2RenderingContext | WebGLRenderingContext | null
|
||||
if (!gl)
|
||||
return true
|
||||
|
||||
return isCanvasRegionTransparent({
|
||||
gl,
|
||||
clientX: xRef.value,
|
||||
clientY: yRef.value,
|
||||
left: left.value,
|
||||
top: top.value,
|
||||
width: width.value,
|
||||
height: height.value,
|
||||
radius,
|
||||
threshold,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Generated
+22
-3
@@ -63,6 +63,9 @@ catalogs:
|
||||
electron-updater:
|
||||
specifier: ^6.6.2
|
||||
version: 6.6.2
|
||||
es-toolkit:
|
||||
specifier: ^1.43.0
|
||||
version: 1.43.0
|
||||
posthog-js:
|
||||
specifier: 1.306.1
|
||||
version: 1.306.1
|
||||
@@ -1574,6 +1577,9 @@ importers:
|
||||
dompurify:
|
||||
specifier: ^3.3.1
|
||||
version: 3.3.1
|
||||
es-toolkit:
|
||||
specifier: 'catalog:'
|
||||
version: 1.43.0
|
||||
gpuu:
|
||||
specifier: ^1.0.6
|
||||
version: 1.0.6
|
||||
@@ -1909,7 +1915,7 @@ importers:
|
||||
version: rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite-plugin-vue-devtools:
|
||||
specifier: ^8.0.5
|
||||
version: 8.0.5(@nuxt/kit@4.0.3(magicast@0.5.1))(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
|
||||
version: 8.0.5(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
|
||||
vue-router:
|
||||
specifier: ^4.6.4
|
||||
version: 4.6.4(vue@3.5.25(typescript@5.9.3))
|
||||
@@ -1943,7 +1949,7 @@ importers:
|
||||
version: rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite-plugin-vue-devtools:
|
||||
specifier: ^8.0.5
|
||||
version: 8.0.5(@nuxt/kit@4.0.3(magicast@0.5.1))(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
|
||||
version: 8.0.5(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
|
||||
vue-router:
|
||||
specifier: ^4.6.4
|
||||
version: 4.6.4(vue@3.5.25(typescript@5.9.3))
|
||||
@@ -32908,7 +32914,6 @@ snapshots:
|
||||
'@nuxt/kit': 3.20.2(magicast@0.3.5)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
vite-plugin-inspect@11.3.3(@nuxt/kit@3.20.2(magicast@0.3.5))(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
|
||||
dependencies:
|
||||
@@ -32987,6 +32992,20 @@ snapshots:
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
vite-plugin-vue-devtools@8.0.5(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-core': 8.0.5(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
|
||||
'@vue/devtools-kit': 8.0.5
|
||||
'@vue/devtools-shared': 8.0.5
|
||||
sirv: 3.0.2
|
||||
vite: rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite-plugin-inspect: 11.3.3(@nuxt/kit@3.20.2(magicast@0.3.5))(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
vite-plugin-vue-inspector: 5.3.2(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
vite-plugin-vue-inspector@5.3.2(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
|
||||
+15
-14
@@ -1,7 +1,3 @@
|
||||
catalogMode: prefer
|
||||
|
||||
shellEmulator: true
|
||||
|
||||
packages:
|
||||
- packages/**
|
||||
- plugins/**
|
||||
@@ -11,16 +7,6 @@ packages:
|
||||
- apps/**
|
||||
- '!**/dist/**'
|
||||
|
||||
overrides:
|
||||
array-flatten: npm:@nolyfill/array-flatten@^1.0.44
|
||||
axios: npm:feaxios@^0.0.23
|
||||
is-core-module: npm:@nolyfill/is-core-module@^1.0.39
|
||||
isarray: npm:@nolyfill/isarray@^1.0.44
|
||||
safe-buffer: npm:@nolyfill/safe-buffer@^1.0.44
|
||||
safer-buffer: npm:@nolyfill/safer-buffer@^1.0.44
|
||||
side-channel: npm:@nolyfill/side-channel@^1.0.44
|
||||
string.prototype.matchall: npm:@nolyfill/string.prototype.matchall@^1.0.44
|
||||
|
||||
catalog:
|
||||
'@guiiai/logg': 1.1.0
|
||||
'@moeru/eslint-config': 0.1.0-beta.13
|
||||
@@ -41,9 +27,12 @@ catalog:
|
||||
'@xsai/tool': ^0.4.0-beta.10
|
||||
'@xsai/utils-chat': 0.4.0-beta.5
|
||||
electron-updater: ^6.6.2
|
||||
es-toolkit: ^1.43.0
|
||||
posthog-js: 1.306.1
|
||||
xsschema: ^0.4.0-beta.10
|
||||
|
||||
catalogMode: prefer
|
||||
|
||||
catalogs:
|
||||
rolldown-vite:
|
||||
vite: npm:rolldown-vite@^7.2.11
|
||||
@@ -67,3 +56,15 @@ onlyBuiltDependencies:
|
||||
- spawn-sync
|
||||
- utf-8-validate
|
||||
- vue-demi
|
||||
|
||||
overrides:
|
||||
array-flatten: npm:@nolyfill/array-flatten@^1.0.44
|
||||
axios: npm:feaxios@^0.0.23
|
||||
is-core-module: npm:@nolyfill/is-core-module@^1.0.39
|
||||
isarray: npm:@nolyfill/isarray@^1.0.44
|
||||
safe-buffer: npm:@nolyfill/safe-buffer@^1.0.44
|
||||
safer-buffer: npm:@nolyfill/safer-buffer@^1.0.44
|
||||
side-channel: npm:@nolyfill/side-channel@^1.0.44
|
||||
string.prototype.matchall: npm:@nolyfill/string.prototype.matchall@^1.0.44
|
||||
|
||||
shellEmulator: true
|
||||
|
||||
Reference in New Issue
Block a user