From c4b3ef18a0e19ad1ea290872eeece654ba5481fa Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 28 Dec 2025 13:10:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(wallet-card):=20OffscreenCanvas=20Worker?= =?UTF-8?q?=20+=20=E6=91=A9=E6=93=A6=E5=8A=9B=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 迁移 hologram 渲染到 OffscreenCanvas Worker - 引入逻辑坐标系统实现完美对称 - 优先级调度:high=60fps/全DPR, medium=30fps/1x, low=15fps/0.5x - 页面可见性 API:切换标签页暂停渲染 - 静态模式缓存:mode=static 只渲染一次 - 动态刷新:状态无变化时跳过渲染 - 摩擦力系统:3s 低运动后冻结,过滤桌面微振 --- AGENTS.md | 4 +- src/components/wallet/refraction.tsx | 627 ------------------ .../wallet/refraction/hologram-canvas.tsx | 174 +++++ src/components/wallet/refraction/index.ts | 3 + src/components/wallet/refraction/renderer.ts | 430 ++++++++++++ src/components/wallet/refraction/scheduler.ts | 163 +++++ src/components/wallet/refraction/types.ts | 31 + src/components/wallet/refraction/worker.ts | 186 ++++++ .../wallet/wallet-card-carousel.tsx | 18 +- src/components/wallet/wallet-card.tsx | 10 +- src/hooks/useCardInteraction.ts | 42 +- 11 files changed, 1053 insertions(+), 635 deletions(-) delete mode 100644 src/components/wallet/refraction.tsx create mode 100644 src/components/wallet/refraction/hologram-canvas.tsx create mode 100644 src/components/wallet/refraction/index.ts create mode 100644 src/components/wallet/refraction/renderer.ts create mode 100644 src/components/wallet/refraction/scheduler.ts create mode 100644 src/components/wallet/refraction/types.ts create mode 100644 src/components/wallet/refraction/worker.ts diff --git a/AGENTS.md b/AGENTS.md index 6539b1732..99b141d6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,8 @@ # AI 开发元原则 -本文档定义 AI 开发的**元意识** — 如何思考、如何工作的方法论。 +1. 本文档定义 AI 开发的**元意识** — 如何思考、如何工作的方法论。 +2. 项目具体知识(技术栈、API、业务概念)请查阅 `docs/white-book/`。 -项目具体知识(技术栈、API、业务概念)请查阅 `docs/white-book/`。 --- diff --git a/src/components/wallet/refraction.tsx b/src/components/wallet/refraction.tsx deleted file mode 100644 index 58b0d403d..000000000 --- a/src/components/wallet/refraction.tsx +++ /dev/null @@ -1,627 +0,0 @@ -import { useEffect, useMemo, useRef } from 'react' - -type RenderMode = 'dynamic' | 'static' - -export interface HologramCanvasProps { - enabledPattern: boolean - enabledWatermark: boolean - mode?: RenderMode - /** - * 指针位置(-1~1) - * - x: 左(-1) → 右(1) - * - y: 上(-1) → 下(1) - */ - pointerX: number - pointerY: number - active: boolean - themeHue: number - /** - * 水印 mask(白色 + alpha),用于在 Canvas 内生成平铺 watermark。 - * 该 URL 通常来自 useMonochromeMask。 - */ - watermarkMaskUrl?: string | null - /** 水印单元格尺寸(CSS px) */ - watermarkCellSize?: number - /** 水印图标绘制尺寸(CSS px) */ - watermarkIconSize?: number -} - -const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)) - -function normalizeHueDegrees(value: number) { - const mod = value % 360 - return mod < 0 ? mod + 360 : mod -} - -function srgbEncode(linear: number) { - const v = clamp(linear, 0, 1) - if (v <= 0.0031308) return 12.92 * v - return 1.055 * Math.pow(v, 1 / 2.4) - 0.055 -} - -function oklchToSrgbCssColor(L: number, C: number, H: number) { - const hr = (normalizeHueDegrees(H) * Math.PI) / 180 - const a = C * Math.cos(hr) - const b = C * Math.sin(hr) - - // OKLab -> LMS (non-linear) - const l_ = L + 0.3963377774 * a + 0.2158037573 * b - const m_ = L - 0.1055613458 * a - 0.0638541728 * b - const s_ = L - 0.0894841775 * a - 1.291485548 * b - - const l = l_ * l_ * l_ - const m = m_ * m_ * m_ - const s = s_ * s_ * s_ - - // linear sRGB - const rLin = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s - const gLin = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s - const bLin = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s - - const r = clamp(Math.round(srgbEncode(rLin) * 255), 0, 255) - const g = clamp(Math.round(srgbEncode(gLin) * 255), 0, 255) - const bb = clamp(Math.round(srgbEncode(bLin) * 255), 0, 255) - - return `rgb(${r}, ${g}, ${bb})` -} - -// 纹理(mask)由业务方提供;这里使用 KeyApp 当前的三角纹理(等价于原 DOM mask)。 -const TRIANGLE_MASK_DATA_URL = - 'data:image/svg+xml,%3Csvg%20width%3D%2710%27%20height%3D%2710%27%20viewBox%3D%270%200%2010%2010%27%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%3E%3Cpath%20d%3D%27M0%2010%20L10%2010%20L10%200%20Z%27%20fill%3D%27black%27%2F%3E%3C%2Fsvg%3E' - -interface SizeInfo { - width: number - height: number - dpr: number - pxW: number - pxH: number -} - -function resizeCanvasToContainer(canvas: HTMLCanvasElement): SizeInfo { - const parent = canvas.parentElement - if (!parent) return { width: 0, height: 0, dpr: 1, pxW: 0, pxH: 0 } - - const rect = parent.getBoundingClientRect() - const dpr = typeof window === 'undefined' ? 1 : Math.max(1, Math.min(3, window.devicePixelRatio || 1)) - const width = Math.max(1, Math.round(rect.width)) - const height = Math.max(1, Math.round(rect.height)) - - // 重要:canvas 的 width/height attribute 是绘制缓冲区像素尺寸(device pixels), - // 但 CSS 布局尺寸必须保持为未缩放的 CSS px,否则高 DPR 设备会“看起来被放大”。 - // 仅依赖 absolute/inset 在部分 Android WebView 上不够稳定,这里显式写入。 - const cssW = `${width}px` - const cssH = `${height}px` - if (canvas.style.width !== cssW) canvas.style.width = cssW - if (canvas.style.height !== cssH) canvas.style.height = cssH - - const pxW = Math.max(1, Math.round(width * dpr)) - const pxH = Math.max(1, Math.round(height * dpr)) - - if (canvas.width !== pxW) canvas.width = pxW - if (canvas.height !== pxH) canvas.height = pxH - - return { width, height, dpr, pxW, pxH } -} - -interface BackgroundStops { - themeHue: number - c0: string - c1: string - c2: string -} - -function resolveBackgroundStops(themeHue: number): BackgroundStops | null { - // themeHue 是 OKLCH 的 hue(0~360)。 - // 这里直接做 OKLCH -> sRGB 转换,避免依赖浏览器对 oklch() 的 computed-style 输出格式差异。 - return { - themeHue, - c0: oklchToSrgbCssColor(0.5, 0.2, themeHue), - c1: oklchToSrgbCssColor(0.4, 0.22, themeHue + 20), - c2: oklchToSrgbCssColor(0.3, 0.18, themeHue + 40), - } -} - -function drawBackground(ctx: CanvasRenderingContext2D, w: number, h: number, bg: BackgroundStops | null) { - ctx.save() - ctx.globalCompositeOperation = 'source-over' - ctx.globalAlpha = 1 - ctx.filter = 'none' - - if (bg) { - const grad = ctx.createLinearGradient(0, 0, w, h) - grad.addColorStop(0, bg.c0) - grad.addColorStop(0.5, bg.c1) - grad.addColorStop(1, bg.c2) - ctx.fillStyle = grad - } else { - ctx.fillStyle = 'rgb(0, 0, 0)' - } - - ctx.fillRect(0, 0, w, h) - ctx.restore() -} - -function centeredPatternOffset(containerPx: number, tilePx: number) { - if (!(tilePx > 0)) return 0 - const halfTile = tilePx / 2 - const desired = containerPx / 2 - halfTile - const mod = ((desired % tilePx) + tilePx) % tilePx - return mod -} - -function fillWithCenteredPattern(ctx: CanvasRenderingContext2D, pattern: CanvasPattern, w: number, h: number, tilePx: number) { - const ox = centeredPatternOffset(w, tilePx) - const oy = centeredPatternOffset(h, tilePx) - - ctx.save() - ctx.translate(-ox, -oy) - ctx.fillStyle = pattern - // 多画一圈,避免偏移后边缘漏填 - ctx.fillRect(-tilePx, -tilePx, w + tilePx * 2, h + tilePx * 2) - ctx.restore() -} - -interface RefractionCornerConfig { - corner: 'bottom-left' | 'top-right' - sizeMultiplier: 5 - translateX: { base: number; xFactor: number; clamp: number } - translateY: { yFactor: number; mode: 'max0' | 'min0' } - scale: { base: number; xFactor: number } -} - -const REFRACTION_CORNERS: RefractionCornerConfig[] = [ - { - corner: 'bottom-left', - sizeMultiplier: 5, - translateX: { base: -0.1, xFactor: 0.1, clamp: 0.1 }, - translateY: { yFactor: -0.1, mode: 'max0' }, - scale: { base: 0.15, xFactor: 0.25 }, - }, - { - corner: 'top-right', - sizeMultiplier: 5, - translateX: { base: 0.1, xFactor: 0.1, clamp: 0.1 }, - translateY: { yFactor: -0.1, mode: 'min0' }, - scale: { base: 0.15, xFactor: -0.65 }, - }, -] - -function getDemoGradientStops() { - // DEMO:transparent 10% ... transparent 60%,中间 3 个颜色 stop 未指定位置 => 平均分布 - const start = 0.1 - const end = 0.6 - const step = (end - start) / 4 - - return [ - { stop: start, color: 'rgba(0,0,0,0)' }, - // hsl(5 100% 80%), hsl(150 100% 60%), hsl(220 90% 70%) - { stop: start + step * 1, color: 'rgb(255, 162, 153)' }, - { stop: start + step * 2, color: 'rgb(51, 255, 153)' }, - { stop: start + step * 3, color: 'rgb(110, 156, 247)' }, - { stop: end, color: 'rgba(0,0,0,0)' }, - ] as const -} - -function drawRefraction( - ctx: CanvasRenderingContext2D, - config: RefractionCornerConfig, - canvas: { w: number; h: number }, - pointer: { x: number; y: number }, - alpha: number, -) { - if (!(alpha > 0)) return - - // 复刻 DEMO:refraction 宽度 500%(按容器宽度),并保持正方形 - const elementSide = canvas.w * config.sizeMultiplier - // radial-gradient(circle ...) 默认 size = farthest-corner - const radius = elementSide * Math.SQRT2 - - const scale = Math.min(1, config.scale.base + pointer.x * config.scale.xFactor) - - const tx = - clamp(config.translateX.base + pointer.x * config.translateX.xFactor, -config.translateX.clamp, config.translateX.clamp) * - elementSide - - const rawTy = pointer.y * config.translateY.yFactor * elementSide - const ty = config.translateY.mode === 'max0' ? Math.max(0, rawTy) : Math.min(0, rawTy) - - let originX = 0 - let originY = 0 - let elementBoxX = 0 - let elementBoxY = 0 - - if (config.corner === 'bottom-left') { - originX = 0 - originY = canvas.h - // absolute bottom-0 left-0: box 从左下角向右上延伸 - elementBoxX = 0 - elementBoxY = -elementSide - } else { - originX = canvas.w - originY = 0 - // absolute top-0 right-0: box 从右上角向左下延伸 - elementBoxX = -elementSide - elementBoxY = 0 - } - - ctx.save() - ctx.globalAlpha = alpha - ctx.translate(originX, originY) - ctx.translate(tx, ty) - ctx.scale(scale, scale) - // DEMO:单个 refraction 元素 filter:saturate(2) - ctx.filter = 'saturate(2)' - - const grad = ctx.createRadialGradient(0, 0, 0, 0, 0, radius) - for (const s of getDemoGradientStops()) grad.addColorStop(s.stop, s.color) - - ctx.fillStyle = grad - ctx.fillRect(elementBoxX, elementBoxY, elementSide, elementSide) - ctx.restore() -} - -function ensureScaledPattern( - ctx: CanvasRenderingContext2D, - cache: { key: string; pattern: CanvasPattern; tilePx: number } | null, - key: string, - image: HTMLImageElement, - tilePx: number, -) { - if (cache && cache.key === key) return cache - - const tile = document.createElement('canvas') - tile.width = tilePx - tile.height = tilePx - const tctx = tile.getContext('2d') - if (!tctx) return null - - tctx.clearRect(0, 0, tile.width, tile.height) - tctx.drawImage(image, 0, 0, tile.width, tile.height) - - const pattern = ctx.createPattern(tile, 'repeat') - if (!pattern) return null - - return { key, pattern, tilePx } -} - -function ensureCenteredIconPattern( - ctx: CanvasRenderingContext2D, - cache: { key: string; pattern: CanvasPattern; tilePx: number } | null, - key: string, - image: HTMLImageElement, - cellPx: number, - iconPx: number, -) { - if (cache && cache.key === key) return cache - - const tile = document.createElement('canvas') - tile.width = cellPx - tile.height = cellPx - const tctx = tile.getContext('2d') - if (!tctx) return null - - tctx.clearRect(0, 0, tile.width, tile.height) - const offset = (cellPx - iconPx) / 2 - tctx.drawImage(image, offset, offset, iconPx, iconPx) - - const pattern = ctx.createPattern(tile, 'repeat') - if (!pattern) return null - - return { key, pattern, tilePx: cellPx } -} - -function renderMaskedLayer( - layerCtx: CanvasRenderingContext2D, - canvas: { w: number; h: number }, - pointer: { x: number; y: number }, - refractionAlpha: number, - baseFill: string, - mask: { pattern: CanvasPattern; tilePx: number }, -) { - layerCtx.save() - layerCtx.setTransform(1, 0, 0, 1, 0, 0) - layerCtx.clearRect(0, 0, canvas.w, canvas.h) - layerCtx.globalCompositeOperation = 'source-over' - layerCtx.globalAlpha = 1 - layerCtx.filter = 'none' - - // DEMO:pattern/watermark 自身有 ::before 的纯色底 - layerCtx.fillStyle = baseFill - layerCtx.fillRect(0, 0, canvas.w, canvas.h) - - for (const cfg of REFRACTION_CORNERS) { - drawRefraction(layerCtx, cfg, canvas, pointer, refractionAlpha) - } - - // 应用 mask(等价于 CSS mask) - layerCtx.globalCompositeOperation = 'destination-in' - layerCtx.globalAlpha = 1 - layerCtx.filter = 'none' - fillWithCenteredPattern(layerCtx, mask.pattern, canvas.w, canvas.h, mask.tilePx) - - layerCtx.restore() -} - -function drawSpotlight( - ctx: CanvasRenderingContext2D, - canvas: { w: number; h: number }, - pointer: { x: number; y: number }, - alpha: number, -) { - if (!(alpha > 0)) return - - // 对齐 DEMO:spotlight::before width=500%(基于 card 宽度),translate(-50% + pointer*20%) - const elementSide = canvas.w * 5 - const radius = (elementSide * Math.SQRT2) / 2 - - const cx = canvas.w / 2 + pointer.x * canvas.w - const cy = canvas.h / 2 + pointer.y * canvas.w - - ctx.save() - ctx.globalCompositeOperation = 'overlay' - ctx.globalAlpha = alpha - ctx.filter = 'brightness(1.2) contrast(1.2)' - - const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius) - grad.addColorStop(0, 'rgba(255,255,255,0.4)') - grad.addColorStop(0.02, 'rgba(255,255,255,0.4)') - grad.addColorStop(0.2, 'rgba(26,26,26,0.2)') - grad.addColorStop(1, 'rgba(26,26,26,0.2)') - - ctx.fillStyle = grad - ctx.fillRect(0, 0, canvas.w, canvas.h) - ctx.restore() -} - -export function HologramCanvas({ - enabledPattern, - enabledWatermark, - mode = 'dynamic', - pointerX, - pointerY, - active, - themeHue, - watermarkMaskUrl, - watermarkCellSize, - watermarkIconSize, -}: HologramCanvasProps) { - const canvasRef = useRef(null) - const scheduleRenderRef = useRef<(() => void) | null>(null) - - const stablePointer = useMemo(() => { - if (mode === 'static') return { x: 0, y: 0 } - return { x: clamp(pointerX, -1, 1), y: clamp(pointerY, -1, 1) } - }, [mode, pointerX, pointerY]) - - const stableActive = mode === 'static' ? false : active - const renderStateRef = useRef({ - enabledPattern, - enabledWatermark, - pointer: stablePointer, - active: stableActive, - watermarkMaskUrl, - watermarkCellSize, - watermarkIconSize, - }) - renderStateRef.current = { - enabledPattern, - enabledWatermark, - pointer: stablePointer, - active: stableActive, - watermarkMaskUrl, - watermarkCellSize, - watermarkIconSize, - } - - const bgStopsRef = useRef(null) - useEffect(() => { - bgStopsRef.current = resolveBackgroundStops(themeHue) - scheduleRenderRef.current?.() - }, [themeHue]) - - const triangleMaskImageRef = useRef(null) - useEffect(() => { - let canceled = false - const img = new Image() - img.onload = () => { - if (canceled) return - triangleMaskImageRef.current = img - scheduleRenderRef.current?.() - } - img.onerror = () => { - if (canceled) return - triangleMaskImageRef.current = null - scheduleRenderRef.current?.() - } - img.src = TRIANGLE_MASK_DATA_URL - return () => { - canceled = true - } - }, []) - - const watermarkMaskImageRef = useRef(null) - useEffect(() => { - if (!watermarkMaskUrl) { - watermarkMaskImageRef.current = null - scheduleRenderRef.current?.() - return - } - - let canceled = false - const img = new Image() - img.crossOrigin = 'anonymous' - img.onload = () => { - if (canceled) return - watermarkMaskImageRef.current = img - scheduleRenderRef.current?.() - } - img.onerror = () => { - if (canceled) return - watermarkMaskImageRef.current = null - scheduleRenderRef.current?.() - } - img.src = watermarkMaskUrl - return () => { - canceled = true - } - }, [watermarkMaskUrl]) - - const patternLayerRef = useRef(null) - const watermarkLayerRef = useRef(null) - - const patternMaskCacheRef = useRef<{ key: string; pattern: CanvasPattern; tilePx: number } | null>(null) - const watermarkMaskCacheRef = useRef<{ key: string; pattern: CanvasPattern; tilePx: number } | null>(null) - - useEffect(() => { - const canvas = canvasRef.current - if (!canvas) return - - const ctx = canvas.getContext('2d') - if (!ctx) return - - let rafId: number | null = null - - const render = () => { - const state = renderStateRef.current - const size = resizeCanvasToContainer(canvas) - if (size.pxW <= 0 || size.pxH <= 0) return - - const w = size.pxW - const h = size.pxH - - ctx.setTransform(1, 0, 0, 1, 0, 0) - ctx.clearRect(0, 0, canvas.width, canvas.height) - - drawBackground(ctx, w, h, bgStopsRef.current) - - const refractionAlpha = state.active ? 1 : 0 - - // DEMO:pattern layer => mix-blend-mode:multiply + opacity:0.4 + filter:saturate/contrast/brightness - if (state.enabledPattern) { - const maskImg = triangleMaskImageRef.current - if (maskImg) { - const layer = patternLayerRef.current ?? document.createElement('canvas') - if (patternLayerRef.current !== layer) patternLayerRef.current = layer - if (layer.width !== w) layer.width = w - if (layer.height !== h) layer.height = h - const layerCtx = layer.getContext('2d') - if (layerCtx) { - const tilePx = Math.max(1, Math.round(24 * size.dpr)) - const maskKey = `triangle:${tilePx}` - const nextCache = ensureScaledPattern(layerCtx, patternMaskCacheRef.current, maskKey, maskImg, tilePx) - if (nextCache) { - patternMaskCacheRef.current = nextCache - - renderMaskedLayer( - layerCtx, - { w, h }, - state.pointer, - refractionAlpha, - 'rgb(204, 204, 204)', - { pattern: nextCache.pattern, tilePx: nextCache.tilePx }, - ) - - ctx.save() - ctx.globalCompositeOperation = 'multiply' - ctx.globalAlpha = 0.4 - ctx.filter = 'saturate(0.8) contrast(1) brightness(1)' - ctx.drawImage(layer, 0, 0) - ctx.restore() - } - } - } - } - - // DEMO:watermark layer => mix-blend-mode:hard-light + opacity:1 + filter:saturate/contrast/brightness - if (state.enabledWatermark) { - const maskImg = watermarkMaskImageRef.current - const cellSize = state.watermarkCellSize ?? 40 - const iconSize = state.watermarkIconSize ?? 24 - - if (maskImg && cellSize > 0 && iconSize > 0) { - const layer = watermarkLayerRef.current ?? document.createElement('canvas') - if (watermarkLayerRef.current !== layer) watermarkLayerRef.current = layer - if (layer.width !== w) layer.width = w - if (layer.height !== h) layer.height = h - const layerCtx = layer.getContext('2d') - if (layerCtx) { - const cellPx = Math.max(1, Math.round(cellSize * size.dpr)) - const iconPx = Math.max(1, Math.round(iconSize * size.dpr)) - const maskKey = `cell:${state.watermarkMaskUrl ?? 'null'}:${cellPx}:${iconPx}` - const nextCache = ensureCenteredIconPattern(layerCtx, watermarkMaskCacheRef.current, maskKey, maskImg, cellPx, iconPx) - if (nextCache) { - watermarkMaskCacheRef.current = nextCache - - renderMaskedLayer(layerCtx, { w, h }, state.pointer, refractionAlpha, 'rgba(255, 255, 255, 0.2)', { - pattern: nextCache.pattern, - tilePx: nextCache.tilePx, - }) - - ctx.save() - ctx.globalCompositeOperation = 'hard-light' - ctx.globalAlpha = 1 - ctx.filter = 'saturate(0.9) contrast(1.1) brightness(1.2)' - ctx.drawImage(layer, 0, 0) - ctx.restore() - } - } - } - } - - // DEMO:spotlight => mix-blend-mode: overlay(hover/tap 时出现) - drawSpotlight(ctx, { w, h }, state.pointer, state.active ? 1 : 0) - } - - const schedule = () => { - if (rafId != null) return - rafId = window.requestAnimationFrame(() => { - rafId = null - render() - }) - } - - scheduleRenderRef.current = schedule - - const parent = canvas.parentElement - const ro = - typeof ResizeObserver !== 'undefined' && parent - ? new ResizeObserver(() => { - schedule() - }) - : null - - ro?.observe(parent!) - schedule() - - return () => { - scheduleRenderRef.current = null - if (rafId != null) window.cancelAnimationFrame(rafId) - ro?.disconnect() - } - }, []) - - // props 变化时触发渲染(避免 effect 里创建新的 ResizeObserver) - useEffect(() => { - scheduleRenderRef.current?.() - }, [ - enabledPattern, - enabledWatermark, - stableActive, - stablePointer, - themeHue, - watermarkMaskUrl, - watermarkCellSize, - watermarkIconSize, - ]) - - return ( - - ) -} diff --git a/src/components/wallet/refraction/hologram-canvas.tsx b/src/components/wallet/refraction/hologram-canvas.tsx new file mode 100644 index 000000000..80a4cdae6 --- /dev/null +++ b/src/components/wallet/refraction/hologram-canvas.tsx @@ -0,0 +1,174 @@ +import { useEffect, useId, useRef, useCallback } from 'react' +import { hologramScheduler } from './scheduler' +import type { Priority } from './types' + +type RenderMode = 'dynamic' | 'static' + +export interface HologramCanvasProps { + priority?: Priority + enabledPattern: boolean + enabledWatermark: boolean + mode?: RenderMode + pointerX: number + pointerY: number + active: boolean + themeHue: number + watermarkMaskUrl?: string | null + watermarkCellSize?: number + watermarkIconSize?: number +} + +const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)) + +export function HologramCanvas({ + priority = 'high', + enabledPattern, + enabledWatermark, + mode = 'dynamic', + pointerX, + pointerY, + active, + themeHue, + watermarkMaskUrl, + watermarkCellSize, + watermarkIconSize, +}: HologramCanvasProps) { + const cardId = useId() + const canvasRef = useRef(null) + const sizeRef = useRef({ width: 0, height: 0, dpr: 1 }) + const transferredRef = useRef(false) // Track if canvas was transferred (can only happen once) + const registeredRef = useRef(false) + + // Store current props in ref for ResizeObserver callback + const propsRef = useRef({ + priority, + mode, + pointerX, + pointerY, + active, + themeHue, + enabledPattern, + enabledWatermark, + watermarkMaskUrl, + watermarkCellSize, + watermarkIconSize, + }) + propsRef.current = { + priority, + mode, + pointerX, + pointerY, + active, + themeHue, + enabledPattern, + enabledWatermark, + watermarkMaskUrl, + watermarkCellSize, + watermarkIconSize, + } + + // Schedule state update (reads from refs to avoid stale closures) + const scheduleUpdate = useCallback(() => { + queueMicrotask(() => { + if (!registeredRef.current) return + + const props = propsRef.current + const pointer = + props.mode === 'static' ? { x: 0, y: 0 } : { x: clamp(props.pointerX, -1, 1), y: clamp(props.pointerY, -1, 1) } + + hologramScheduler.update({ + cardId, + priority: props.priority, + mode: props.mode, + ...sizeRef.current, + pointer, + active: props.mode === 'static' ? false : props.active, + themeHue: props.themeHue, + enabledPattern: props.enabledPattern, + enabledWatermark: props.enabledWatermark, + watermarkMaskUrl: props.watermarkMaskUrl, + watermarkCellSize: props.watermarkCellSize, + watermarkIconSize: props.watermarkIconSize, + }) + }) + }, [cardId]) + + // Register canvas with scheduler (only once - canvas can only be transferred once) + useEffect(() => { + const canvas = canvasRef.current + if (!canvas || transferredRef.current) return + + transferredRef.current = true + registeredRef.current = true + hologramScheduler.register(cardId, canvas) + + // Note: 不 unregister 因为: + // 1. canvas 只能 transfer 一次,cleanup 后无法重新注册 + // 2. React Strict Mode 会 mount → unmount → mount + // 3. Worker 会保持引用,但组件卸载后不再收到 state 更新,自然停止渲染 + }, [cardId]) + + // Observe size changes + useEffect(() => { + const parent = canvasRef.current?.parentElement + if (!parent) return + + const updateSize = () => { + const rect = parent.getBoundingClientRect() + const dpr = Math.min(3, window.devicePixelRatio || 1) + sizeRef.current = { + width: Math.max(1, Math.round(rect.width)), + height: Math.max(1, Math.round(rect.height)), + dpr, + } + } + + const ro = new ResizeObserver(() => { + updateSize() + scheduleUpdate() + }) + ro.observe(parent) + updateSize() + // Initial update after size is set + scheduleUpdate() + + return () => ro.disconnect() + }, [scheduleUpdate]) + + // Preload watermark image + useEffect(() => { + if (watermarkMaskUrl) { + hologramScheduler.loadWatermark(watermarkMaskUrl, watermarkMaskUrl) + } + }, [watermarkMaskUrl]) + + // Update state when props change + useEffect(() => { + scheduleUpdate() + }, [ + scheduleUpdate, + priority, + mode, + pointerX, + pointerY, + active, + themeHue, + enabledPattern, + enabledWatermark, + watermarkMaskUrl, + watermarkCellSize, + watermarkIconSize, + ]) + + return ( + + ) +} diff --git a/src/components/wallet/refraction/index.ts b/src/components/wallet/refraction/index.ts new file mode 100644 index 000000000..d1ed3a8fa --- /dev/null +++ b/src/components/wallet/refraction/index.ts @@ -0,0 +1,3 @@ +export { HologramCanvas } from './hologram-canvas' +export type { HologramCanvasProps } from './hologram-canvas' +export type { Priority } from './types' diff --git a/src/components/wallet/refraction/renderer.ts b/src/components/wallet/refraction/renderer.ts new file mode 100644 index 000000000..54d52a510 --- /dev/null +++ b/src/components/wallet/refraction/renderer.ts @@ -0,0 +1,430 @@ +import type { RenderState } from './types' + +type Ctx = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D + +// ============================================================================ +// 逻辑尺寸系统 +// 设计空间使用逻辑单位,渲染时缩放到物理像素 +// 卡片比例基于银行卡标准 85.6mm × 53.98mm ≈ 8:5 +// ============================================================================ +const LOGICAL_WIDTH = 16 // 逻辑宽度(选择能被常见间距整除的值) +const LOGICAL_HEIGHT = 10 // 逻辑高度(16:10 = 8:5) + +// 纹理逻辑间距:16÷1=16, 10÷1=10 ✓ 完美整除 +const PATTERN_LOGICAL_SPACING = 1 + +// 水印逻辑间距:16÷2=8, 10÷2=5 ✓ 完美整除 +const WATERMARK_LOGICAL_SPACING = 2 + +const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)) + +function normalizeHueDegrees(value: number) { + const mod = value % 360 + return mod < 0 ? mod + 360 : mod +} + +function srgbEncode(linear: number) { + const v = clamp(linear, 0, 1) + if (v <= 0.0031308) return 12.92 * v + return 1.055 * Math.pow(v, 1 / 2.4) - 0.055 +} + +function oklchToSrgbCssColor(L: number, C: number, H: number) { + const hr = (normalizeHueDegrees(H) * Math.PI) / 180 + const a = C * Math.cos(hr) + const b = C * Math.sin(hr) + + const l_ = L + 0.3963377774 * a + 0.2158037573 * b + const m_ = L - 0.1055613458 * a - 0.0638541728 * b + const s_ = L - 0.0894841775 * a - 1.291485548 * b + + const l = l_ * l_ * l_ + const m = m_ * m_ * m_ + const s = s_ * s_ * s_ + + const rLin = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s + const gLin = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s + const bLin = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s + + const r = clamp(Math.round(srgbEncode(rLin) * 255), 0, 255) + const g = clamp(Math.round(srgbEncode(gLin) * 255), 0, 255) + const bb = clamp(Math.round(srgbEncode(bLin) * 255), 0, 255) + + return `rgb(${r}, ${g}, ${bb})` +} + +export interface BackgroundStops { + themeHue: number + c0: string + c1: string + c2: string +} + +export function resolveBackgroundStops(themeHue: number): BackgroundStops { + return { + themeHue, + c0: oklchToSrgbCssColor(0.5, 0.2, themeHue), + c1: oklchToSrgbCssColor(0.4, 0.22, themeHue + 20), + c2: oklchToSrgbCssColor(0.3, 0.18, themeHue + 40), + } +} + +function drawBackground(ctx: Ctx, w: number, h: number, bg: BackgroundStops | null) { + ctx.save() + ctx.globalCompositeOperation = 'source-over' + ctx.globalAlpha = 1 + ctx.filter = 'none' + + if (bg) { + const grad = ctx.createLinearGradient(0, 0, w, h) + grad.addColorStop(0, bg.c0) + grad.addColorStop(0.5, bg.c1) + grad.addColorStop(1, bg.c2) + ctx.fillStyle = grad + } else { + ctx.fillStyle = 'rgb(0, 0, 0)' + } + + ctx.fillRect(0, 0, w, h) + ctx.restore() +} + +interface RefractionCornerConfig { + corner: 'bottom-left' | 'top-right' + sizeMultiplier: 5 + translateX: { base: number; xFactor: number; clamp: number } + translateY: { yFactor: number; mode: 'max0' | 'min0' } + scale: { base: number; xFactor: number } +} + +const REFRACTION_CORNERS: RefractionCornerConfig[] = [ + { + corner: 'bottom-left', + sizeMultiplier: 5, + translateX: { base: -0.1, xFactor: 0.1, clamp: 0.1 }, + translateY: { yFactor: -0.1, mode: 'max0' }, + scale: { base: 0.15, xFactor: 0.25 }, + }, + { + corner: 'top-right', + sizeMultiplier: 5, + translateX: { base: 0.1, xFactor: 0.1, clamp: 0.1 }, + translateY: { yFactor: -0.1, mode: 'min0' }, + scale: { base: 0.15, xFactor: -0.65 }, + }, +] + +function getDemoGradientStops() { + const start = 0.1 + const end = 0.6 + const step = (end - start) / 4 + + return [ + { stop: start, color: 'rgba(0,0,0,0)' }, + { stop: start + step * 1, color: 'rgb(255, 162, 153)' }, + { stop: start + step * 2, color: 'rgb(51, 255, 153)' }, + { stop: start + step * 3, color: 'rgb(110, 156, 247)' }, + { stop: end, color: 'rgba(0,0,0,0)' }, + ] as const +} + +function drawRefraction( + ctx: Ctx, + config: RefractionCornerConfig, + canvas: { w: number; h: number }, + pointer: { x: number; y: number }, + alpha: number, +) { + if (!(alpha > 0)) return + + const elementSide = canvas.w * config.sizeMultiplier + const radius = elementSide * Math.SQRT2 + + const scale = Math.min(1, config.scale.base + pointer.x * config.scale.xFactor) + + const tx = + clamp(config.translateX.base + pointer.x * config.translateX.xFactor, -config.translateX.clamp, config.translateX.clamp) * + elementSide + + const rawTy = pointer.y * config.translateY.yFactor * elementSide + const ty = config.translateY.mode === 'max0' ? Math.max(0, rawTy) : Math.min(0, rawTy) + + let originX = 0 + let originY = 0 + let elementBoxX = 0 + let elementBoxY = 0 + + if (config.corner === 'bottom-left') { + originX = 0 + originY = canvas.h + elementBoxX = 0 + elementBoxY = -elementSide + } else { + originX = canvas.w + originY = 0 + elementBoxX = -elementSide + elementBoxY = 0 + } + + ctx.save() + ctx.globalAlpha = alpha + ctx.translate(originX, originY) + ctx.translate(tx, ty) + ctx.scale(scale, scale) + ctx.filter = 'saturate(2)' + + const grad = ctx.createRadialGradient(0, 0, 0, 0, 0, radius) + for (const s of getDemoGradientStops()) grad.addColorStop(s.stop, s.color) + + ctx.fillStyle = grad + ctx.fillRect(elementBoxX, elementBoxY, elementSide, elementSide) + ctx.restore() +} + +export interface PatternCache { + key: string + pattern: CanvasPattern + /** 逻辑空间中的间距 */ + logicalSpacing: number + /** pattern tile 的物理尺寸 */ + tileSize: number +} + +/** + * 创建纹理 pattern(三角形) + * tile 大小基于 source image,逻辑间距固定为 PATTERN_LOGICAL_SPACING + */ +function ensurePatternCache( + ctx: Ctx, + cache: PatternCache | null, + key: string, + image: ImageBitmap, +): PatternCache | null { + if (cache && cache.key === key) return cache + + // 使用 source image 的原始尺寸作为 tile + const tileSize = image.width // 假设是正方形 + const tile = new OffscreenCanvas(tileSize, tileSize) + const tctx = tile.getContext('2d') + if (!tctx) return null + + tctx.clearRect(0, 0, tile.width, tile.height) + tctx.drawImage(image, 0, 0) + + const pattern = ctx.createPattern(tile, 'repeat') + if (!pattern) return null + + return { key, pattern, logicalSpacing: PATTERN_LOGICAL_SPACING, tileSize } +} + +/** + * 创建水印 pattern(图标居中在 cell 内) + * 逻辑间距固定为 WATERMARK_LOGICAL_SPACING + * @param cellPx cell 物理尺寸(用于创建 tile) + * @param iconPx 图标物理尺寸 + */ +function ensureWatermarkCache( + ctx: Ctx, + cache: PatternCache | null, + key: string, + image: ImageBitmap, + cellPx: number, + iconPx: number, +): PatternCache | null { + if (cache && cache.key === key) return cache + + const tileSize = cellPx + const tile = new OffscreenCanvas(tileSize, tileSize) + const tctx = tile.getContext('2d') + if (!tctx) return null + + tctx.clearRect(0, 0, tile.width, tile.height) + // 图标在 cell 内居中绘制 + const iconOffset = (tileSize - iconPx) / 2 + tctx.drawImage(image, iconOffset, iconOffset, iconPx, iconPx) + + const pattern = ctx.createPattern(tile, 'repeat') + if (!pattern) return null + + return { key, pattern, logicalSpacing: WATERMARK_LOGICAL_SPACING, tileSize } +} + +function renderMaskedLayer( + layerCtx: Ctx, + canvas: { w: number; h: number }, + pointer: { x: number; y: number }, + refractionAlpha: number, + baseFill: string, + mask: { pattern: CanvasPattern; logicalSpacing: number; tileSize: number }, +) { + layerCtx.save() + layerCtx.setTransform(1, 0, 0, 1, 0, 0) + layerCtx.clearRect(0, 0, canvas.w, canvas.h) + layerCtx.globalCompositeOperation = 'source-over' + layerCtx.globalAlpha = 1 + layerCtx.filter = 'none' + + layerCtx.fillStyle = baseFill + layerCtx.fillRect(0, 0, canvas.w, canvas.h) + + for (const cfg of REFRACTION_CORNERS) { + drawRefraction(layerCtx, cfg, canvas, pointer, refractionAlpha) + } + + layerCtx.globalCompositeOperation = 'destination-in' + layerCtx.globalAlpha = 1 + layerCtx.filter = 'none' + + // 使用逻辑坐标系统填充 pattern + // 计算目标物理尺寸:logicalSpacing 对应的物理像素 + const targetW = (mask.logicalSpacing / LOGICAL_WIDTH) * canvas.w + const targetH = (mask.logicalSpacing / LOGICAL_HEIGHT) * canvas.h + // pattern tile 需要缩放到目标尺寸 + const scaleX = targetW / mask.tileSize + const scaleY = targetH / mask.tileSize + + const matrix = new DOMMatrix() + matrix.scaleSelf(scaleX, scaleY) + mask.pattern.setTransform(matrix) + + layerCtx.fillStyle = mask.pattern + layerCtx.fillRect(0, 0, canvas.w, canvas.h) + + layerCtx.restore() +} + +function drawSpotlight(ctx: Ctx, canvas: { w: number; h: number }, pointer: { x: number; y: number }, alpha: number) { + if (!(alpha > 0)) return + + const elementSide = canvas.w * 5 + const radius = (elementSide * Math.SQRT2) / 2 + + const cx = canvas.w / 2 + pointer.x * canvas.w + const cy = canvas.h / 2 + pointer.y * canvas.w + + ctx.save() + ctx.globalCompositeOperation = 'overlay' + ctx.globalAlpha = alpha + ctx.filter = 'brightness(1.2) contrast(1.2)' + + const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius) + grad.addColorStop(0, 'rgba(255,255,255,0.4)') + grad.addColorStop(0.02, 'rgba(255,255,255,0.4)') + grad.addColorStop(0.2, 'rgba(26,26,26,0.2)') + grad.addColorStop(1, 'rgba(26,26,26,0.2)') + + ctx.fillStyle = grad + ctx.fillRect(0, 0, canvas.w, canvas.h) + ctx.restore() +} + +export interface RenderResources { + bgStops: BackgroundStops | null + triangleMask: ImageBitmap | null + watermarkMask: ImageBitmap | null + patternLayer: OffscreenCanvas + watermarkLayer: OffscreenCanvas + patternMaskCache: PatternCache | null + watermarkMaskCache: PatternCache | null +} + +export interface RenderResult { + patternMaskCache: PatternCache | null + watermarkMaskCache: PatternCache | null +} + +export function renderHologram(ctx: Ctx, state: RenderState, resources: RenderResources): RenderResult { + const w = Math.round(state.width * state.dpr) + const h = Math.round(state.height * state.dpr) + + if (w <= 0 || h <= 0) { + return { + patternMaskCache: resources.patternMaskCache, + watermarkMaskCache: resources.watermarkMaskCache, + } + } + + ctx.setTransform(1, 0, 0, 1, 0, 0) + ctx.clearRect(0, 0, w, h) + + drawBackground(ctx, w, h, resources.bgStops) + + const refractionAlpha = state.active ? 1 : 0 + let patternMaskCache = resources.patternMaskCache + let watermarkMaskCache = resources.watermarkMaskCache + + // Pattern layer + if (state.enabledPattern && resources.triangleMask) { + const layer = resources.patternLayer + if (layer.width !== w) layer.width = w + if (layer.height !== h) layer.height = h + const layerCtx = layer.getContext('2d') + if (layerCtx) { + // 使用 image 原始尺寸,缩放在渲染时通过逻辑坐标系统处理 + const maskKey = `triangle:${resources.triangleMask.width}` + const nextCache = ensurePatternCache(layerCtx, patternMaskCache, maskKey, resources.triangleMask) + if (nextCache) { + patternMaskCache = nextCache + renderMaskedLayer(layerCtx, { w, h }, state.pointer, refractionAlpha, 'rgb(204, 204, 204)', { + pattern: nextCache.pattern, + logicalSpacing: nextCache.logicalSpacing, + tileSize: nextCache.tileSize, + }) + + ctx.save() + ctx.globalCompositeOperation = 'multiply' + ctx.globalAlpha = 0.4 + ctx.filter = 'saturate(0.8) contrast(1) brightness(1)' + ctx.drawImage(layer, 0, 0) + ctx.restore() + } + } + } + + // Watermark layer + if (state.enabledWatermark && resources.watermarkMask) { + const cellSize = state.watermarkCellSize ?? 40 + const iconSize = state.watermarkIconSize ?? 24 + + if (cellSize > 0 && iconSize > 0) { + const layer = resources.watermarkLayer + if (layer.width !== w) layer.width = w + if (layer.height !== h) layer.height = h + const layerCtx = layer.getContext('2d') + if (layerCtx) { + // 计算物理像素尺寸(用于创建高清 tile) + const cellPx = Math.max(1, Math.round(cellSize * state.dpr)) + const iconPx = Math.max(1, Math.round(iconSize * state.dpr)) + const maskKey = `watermark:${state.watermarkMaskUrl ?? 'null'}:${cellPx}:${iconPx}` + const nextCache = ensureWatermarkCache( + layerCtx, + watermarkMaskCache, + maskKey, + resources.watermarkMask, + cellPx, + iconPx, + ) + if (nextCache) { + watermarkMaskCache = nextCache + renderMaskedLayer(layerCtx, { w, h }, state.pointer, refractionAlpha, 'rgba(255, 255, 255, 0.2)', { + pattern: nextCache.pattern, + logicalSpacing: nextCache.logicalSpacing, + tileSize: nextCache.tileSize, + }) + + ctx.save() + ctx.globalCompositeOperation = 'hard-light' + ctx.globalAlpha = 1 + ctx.filter = 'saturate(0.9) contrast(1.1) brightness(1.2)' + ctx.drawImage(layer, 0, 0) + ctx.restore() + } + } + } + } + + // Spotlight + drawSpotlight(ctx, { w, h }, state.pointer, state.active ? 1 : 0) + + return { patternMaskCache, watermarkMaskCache } +} diff --git a/src/components/wallet/refraction/scheduler.ts b/src/components/wallet/refraction/scheduler.ts new file mode 100644 index 000000000..c9de066c8 --- /dev/null +++ b/src/components/wallet/refraction/scheduler.ts @@ -0,0 +1,163 @@ +import type { RenderState, WorkerMessage, MainMessage } from './types' + +class HologramScheduler { + private worker: Worker | null = null + private ready = false + private pendingRegistrations: Array<{ cardId: string; canvas: OffscreenCanvas }> = [] + private states = new Map() + private loadedWatermarks = new Set() + private rafId: number | null = null + private triangleMaskBitmap: ImageBitmap | null = null + private triangleMaskSent = false + private paused = false // 页面不可见时暂停渲染 + + constructor() { + if (typeof window !== 'undefined') { + this.initWorker() + this.loadTriangleMask() + this.initVisibilityListener() + } + } + + private initVisibilityListener() { + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + this.pause() + } else { + this.resume() + } + }) + } + + private pause() { + this.paused = true + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId) + this.rafId = null + } + } + + private resume() { + this.paused = false + // 恢复时立即刷新一次 + if (this.states.size > 0) { + this.scheduleFlush() + } + } + + private async loadTriangleMask() { + try { + // 生成高分辨率的 triangle mask,基于 devicePixelRatio + const dpr = Math.min(3, window.devicePixelRatio || 1) + const baseSize = 24 + const size = Math.round(baseSize * dpr) + const canvas = document.createElement('canvas') + canvas.width = size + canvas.height = size + const ctx = canvas.getContext('2d') + if (!ctx) return + + // 绘制三角形 (右下角) + ctx.fillStyle = 'black' + ctx.beginPath() + ctx.moveTo(0, size) + ctx.lineTo(size, size) + ctx.lineTo(size, 0) + ctx.closePath() + ctx.fill() + + this.triangleMaskBitmap = await createImageBitmap(canvas) + // Send to worker if ready + this.sendTriangleMaskIfReady() + } catch (e) { + console.error('[HologramScheduler] Failed to load triangle mask:', e) + } + } + + private sendTriangleMaskIfReady() { + if (this.ready && this.worker && this.triangleMaskBitmap && !this.triangleMaskSent) { + this.triangleMaskSent = true + this.worker.postMessage( + { type: 'setTriangleMask', bitmap: this.triangleMaskBitmap } satisfies WorkerMessage, + [this.triangleMaskBitmap], + ) + } + } + + private initWorker() { + try { + this.worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }) + this.worker.onmessage = this.handleMessage + this.worker.onerror = (e) => { + console.error('[HologramScheduler] Worker error:', e) + } + } catch (e) { + console.error('[HologramScheduler] Failed to create worker:', e) + } + } + + private handleMessage = (e: MessageEvent) => { + if (e.data.type === 'ready') { + this.ready = true + // Send pending canvas registrations + for (const reg of this.pendingRegistrations) { + this.worker!.postMessage({ type: 'register', ...reg } satisfies WorkerMessage, [reg.canvas]) + } + this.pendingRegistrations = [] + // Send triangle mask if already loaded + this.sendTriangleMaskIfReady() + // Flush any pending states after worker is ready + this.scheduleFlush() + } + } + + register(cardId: string, canvas: HTMLCanvasElement): () => void { + if (!this.worker) { + return () => {} + } + + const offscreen = canvas.transferControlToOffscreen() + + if (this.ready) { + this.worker.postMessage({ type: 'register', cardId, canvas: offscreen } satisfies WorkerMessage, [offscreen]) + } else { + this.pendingRegistrations.push({ cardId, canvas: offscreen }) + } + + return () => this.unregister(cardId) + } + + unregister(cardId: string) { + this.states.delete(cardId) + this.worker?.postMessage({ type: 'unregister', cardId } satisfies WorkerMessage) + } + + update(state: RenderState) { + this.states.set(state.cardId, state) + this.scheduleFlush() + } + + loadWatermark(key: string, url: string) { + if (this.loadedWatermarks.has(key)) return + this.loadedWatermarks.add(key) + this.worker?.postMessage({ type: 'loadWatermark', key, url } satisfies WorkerMessage) + } + + private scheduleFlush() { + if (this.rafId !== null || this.paused) return + this.rafId = requestAnimationFrame(() => { + this.rafId = null + this.flush() + }) + } + + private flush() { + if (!this.ready || this.states.size === 0 || !this.worker) return + this.worker.postMessage({ + type: 'update', + states: [...this.states.values()], + } satisfies WorkerMessage) + } +} + +export const hologramScheduler = new HologramScheduler() diff --git a/src/components/wallet/refraction/types.ts b/src/components/wallet/refraction/types.ts new file mode 100644 index 000000000..5d33c0943 --- /dev/null +++ b/src/components/wallet/refraction/types.ts @@ -0,0 +1,31 @@ +export type Priority = 'high' | 'medium' | 'low' + +export type RenderMode = 'static' | 'dynamic' + +export interface RenderState { + cardId: string + priority: Priority + mode: RenderMode + width: number + height: number + dpr: number + pointer: { x: number; y: number } + active: boolean + themeHue: number + enabledPattern: boolean + enabledWatermark: boolean + watermarkMaskUrl: string | null | undefined + watermarkCellSize: number | undefined + watermarkIconSize: number | undefined +} + +// Main → Worker +export type WorkerMessage = + | { type: 'register'; cardId: string; canvas: OffscreenCanvas } + | { type: 'unregister'; cardId: string } + | { type: 'update'; states: RenderState[] } + | { type: 'setTriangleMask'; bitmap: ImageBitmap } + | { type: 'loadWatermark'; key: string; url: string } + +// Worker → Main +export type MainMessage = { type: 'ready' } | { type: 'rendered'; cardId: string; timestamp: number } diff --git a/src/components/wallet/refraction/worker.ts b/src/components/wallet/refraction/worker.ts new file mode 100644 index 000000000..db736675f --- /dev/null +++ b/src/components/wallet/refraction/worker.ts @@ -0,0 +1,186 @@ +import { renderHologram, resolveBackgroundStops, type BackgroundStops, type PatternCache } from './renderer' +import type { WorkerMessage, RenderState, Priority } from './types' + +interface CardResources { + canvas: OffscreenCanvas + ctx: OffscreenCanvasRenderingContext2D + patternLayer: OffscreenCanvas + watermarkLayer: OffscreenCanvas + patternMaskCache: PatternCache | null + watermarkMaskCache: PatternCache | null + bgStops: BackgroundStops | null + lastThemeHue: number + // 静态模式缓存 + staticCache: ImageBitmap | null + staticCacheKey: string | null + // 上一次渲染的状态快照(用于跳过无变化的渲染) + lastStateKey: string | null +} + +/** 生成状态快照 key,用于检测变化 */ +function stateKey(s: RenderState, w: number, h: number): string { + return `${w}:${h}:${s.mode}:${s.pointer.x.toFixed(3)}:${s.pointer.y.toFixed(3)}:${s.active}:${s.themeHue}:${s.enabledPattern}:${s.enabledWatermark}:${s.watermarkMaskUrl}` +} + +const cards = new Map() +const watermarkImages = new Map() +let triangleMask: ImageBitmap | null = null + +let frameCount = 0 +const FRAME_DIVISOR: Record = { high: 1, medium: 2, low: 4 } +// medium 和 low 使用固定 DPR 值 +const FIXED_DPR: Record = { high: null, medium: 1, low: 0.5 } + +let pendingStates: RenderState[] = [] +let rafId: number | null = null + +self.onmessage = async (e: MessageEvent) => { + switch (e.data.type) { + case 'register': { + const ctx = e.data.canvas.getContext('2d') + if (ctx) { + cards.set(e.data.cardId, { + canvas: e.data.canvas, + ctx, + patternLayer: new OffscreenCanvas(1, 1), + watermarkLayer: new OffscreenCanvas(1, 1), + patternMaskCache: null, + watermarkMaskCache: null, + bgStops: null, + lastThemeHue: -1, + staticCache: null, + staticCacheKey: null, + lastStateKey: null, + }) + // Trigger render after registration if we have pending states + if (pendingStates.length > 0) { + scheduleRender() + } + } + break + } + case 'unregister': { + const card = cards.get(e.data.cardId) + if (card) { + card.staticCache?.close() // 释放 ImageBitmap 资源 + cards.delete(e.data.cardId) + } + break + } + case 'update': + pendingStates = e.data.states + scheduleRender() + break + case 'setTriangleMask': + triangleMask = e.data.bitmap + // Trigger render after receiving triangle mask + if (pendingStates.length > 0) { + scheduleRender() + } + break + case 'loadWatermark': { + try { + const res = await fetch(e.data.url) + const blob = await res.blob() + const bitmap = await createImageBitmap(blob) + watermarkImages.set(e.data.key, bitmap) + // Trigger render after watermark loads + if (pendingStates.length > 0) { + scheduleRender() + } + } catch { + /* ignore load errors */ + } + break + } + } +} + +function scheduleRender() { + if (rafId !== null) return + rafId = requestAnimationFrame(renderFrame) +} + +function renderFrame() { + rafId = null + frameCount++ + + // Sort by priority: high → medium → low + const sorted = pendingStates.slice().sort((a, b) => priorityOrder(a.priority) - priorityOrder(b.priority)) + + for (const state of sorted) { + // Frame rate division based on priority + if (frameCount % FRAME_DIVISOR[state.priority] !== 0) continue + + const card = cards.get(state.cardId) + if (!card) continue + + // high: 使用完整 devicePixelRatio, medium/low: 使用固定值 + const fixedDpr = FIXED_DPR[state.priority] + const effectiveDpr = fixedDpr ?? state.dpr + const w = Math.max(1, Math.round(state.width * effectiveDpr)) + const h = Math.max(1, Math.round(state.height * effectiveDpr)) + + // 动态刷新优化:状态无变化时跳过渲染 + const currentStateKey = stateKey(state, w, h) + if (card.lastStateKey === currentStateKey) { + continue // 跳过无变化的帧 + } + + if (card.canvas.width !== w) card.canvas.width = w + if (card.canvas.height !== h) card.canvas.height = h + + // 静态模式缓存:mode='static' 时只渲染一次 + if (state.mode === 'static' && card.staticCache && card.staticCacheKey === currentStateKey) { + // 直接使用缓存 + card.ctx.drawImage(card.staticCache, 0, 0) + card.lastStateKey = currentStateKey + continue + } + + // Update bgStops when themeHue changes + if (card.lastThemeHue !== state.themeHue) { + card.bgStops = resolveBackgroundStops(state.themeHue) + card.lastThemeHue = state.themeHue + } + + // Render + const caches = renderHologram( + card.ctx, + { ...state, dpr: effectiveDpr }, + { + bgStops: card.bgStops, + triangleMask, + watermarkMask: state.watermarkMaskUrl ? (watermarkImages.get(state.watermarkMaskUrl) ?? null) : null, + patternLayer: card.patternLayer, + watermarkLayer: card.watermarkLayer, + patternMaskCache: card.patternMaskCache, + watermarkMaskCache: card.watermarkMaskCache, + }, + ) + + card.patternMaskCache = caches.patternMaskCache + card.watermarkMaskCache = caches.watermarkMaskCache + card.lastStateKey = currentStateKey + + // 静态模式:缓存渲染结果 + if (state.mode === 'static') { + card.staticCache?.close() // 释放旧缓存 + card.staticCache = card.canvas.transferToImageBitmap() + card.staticCacheKey = currentStateKey + // 重新绘制到 canvas(因为 transferToImageBitmap 会清空 canvas) + card.ctx.drawImage(card.staticCache, 0, 0) + } + } + + // Continue rendering if any card is active (interactive) + if (pendingStates.some((s) => s.active)) { + scheduleRender() + } +} + +function priorityOrder(p: Priority) { + return p === 'high' ? 0 : p === 'medium' ? 1 : 2 +} + +self.postMessage({ type: 'ready' }) diff --git a/src/components/wallet/wallet-card-carousel.tsx b/src/components/wallet/wallet-card-carousel.tsx index 72f479d9e..994169c3b 100644 --- a/src/components/wallet/wallet-card-carousel.tsx +++ b/src/components/wallet/wallet-card-carousel.tsx @@ -1,4 +1,5 @@ -import { useCallback, useRef, useEffect } from 'react'; +import { useCallback, useRef, useEffect, useState } from 'react'; +import type { Priority } from './refraction'; import { Swiper, SwiperSlide } from 'swiper/react'; import { EffectCards } from 'swiper/modules'; import type { Swiper as SwiperType } from 'swiper'; @@ -53,6 +54,16 @@ export function WalletCardCarousel({ // 找到当前钱包的索引 const currentIndex = wallets.findIndex((w) => w.id === currentWalletId); + // Track active slide index for priority calculation + const [activeIndex, setActiveIndex] = useState(currentIndex >= 0 ? currentIndex : 0); + + // Calculate card priority based on distance from active slide + const getPriority = (index: number): Priority => { + if (index === activeIndex) return 'high'; + if (Math.abs(index - activeIndex) === 1) return 'medium'; + return 'low'; + }; + // 初始化时滑动到当前钱包 useEffect(() => { if (swiperRef.current && currentIndex >= 0) { @@ -63,6 +74,7 @@ export function WalletCardCarousel({ // 滑动切换钱包 const handleSlideChange = useCallback( (swiper: SwiperType) => { + setActiveIndex(swiper.activeIndex); const wallet = wallets[swiper.activeIndex]; if (wallet && wallet.id !== currentWalletId) { onWalletChange?.(wallet.id); @@ -121,12 +133,13 @@ export function WalletCardCarousel({ }} onSwiper={(swiper) => { swiperRef.current = swiper; + setActiveIndex(swiper.activeIndex); }} onSlideChange={handleSlideChange} initialSlide={currentIndex >= 0 ? currentIndex : 0} className="mx-auto h-[212px] w-[min(92vw,360px)] overflow-visible [&_.swiper-slide]:size-full [&_.swiper-slide]:overflow-visible! [&_.swiper-slide]:rounded-2xl" > - {wallets.map((wallet) => { + {wallets.map((wallet, index) => { const walletChain = getWalletChain(wallet); const walletAddress = getWalletAddress(wallet, walletChain); return ( @@ -135,6 +148,7 @@ export function WalletCardCarousel({ wallet={wallet} chain={walletChain} chainName={chainNames[walletChain] ?? walletChain} + priority={getPriority(index)} address={walletAddress} chainIconUrl={chainIconUrls[walletChain]} watermarkLogoSize={40} diff --git a/src/components/wallet/wallet-card.tsx b/src/components/wallet/wallet-card.tsx index f3f68f3ce..ec3d9776d 100644 --- a/src/components/wallet/wallet-card.tsx +++ b/src/components/wallet/wallet-card.tsx @@ -25,7 +25,7 @@ import { useCardInteraction } from '@/hooks/useCardInteraction'; import { useMonochromeMask } from '@/hooks/useMonochromeMask'; import { ChainIcon } from './chain-icon'; import { AddressDisplay } from './address-display'; -import { HologramCanvas } from './refraction'; +import { HologramCanvas, type Priority } from './refraction'; import type { Wallet, ChainType } from '@/stores'; import { IconCopy as Copy, @@ -38,6 +38,8 @@ export interface WalletCardProps { wallet: Wallet; chain: ChainType; chainName: string; + /** 渲染优先级,影响帧率和分辨率 */ + priority?: Priority; address?: string | undefined; /** 链图标 URL,用于防伪水印 */ chainIconUrl?: string | undefined; @@ -74,6 +76,7 @@ export const WalletCard = forwardRef(function W wallet, chain, chainName, + priority = 'high', address, chainIconUrl, watermarkLogoSize = 40, @@ -104,8 +107,10 @@ export const WalletCard = forwardRef(function W const refractionMode = prefersReducedMotion ? ('static' as const) : ('dynamic' as const); // 将链图标转为单色遮罩(黑白 -> 透明) + // 使用 devicePixelRatio 确保高清 + const dpr = typeof window !== 'undefined' ? Math.min(3, window.devicePixelRatio || 1) : 1; const monoMaskUrl = useMonochromeMask(chainIconUrl, { - size: watermarkLogoActualSize * 2, // 2x for retina + size: Math.round(watermarkLogoActualSize * dpr), invert: false, // 白色区域不透明 contrast: 1.8, }); @@ -179,6 +184,7 @@ export const WalletCard = forwardRef(function W > {/* 1~4. 背景 + Pattern/Watermark + Spotlight 全部由 Canvas 完成(不依赖 DOM mix-blend-mode) */} { @@ -51,8 +57,14 @@ export function useCardInteraction(options: CardInteractionOptions = {}) { // 触摸数据 const touchRef = useRef({ x: 0, y: 0, active: false }) - // 合并重力和触摸数据 + // 摩擦力系统状态 + const frictionPhase = useRef<'static' | 'dynamic'>('dynamic') + const lastActiveTime = useRef(Date.now()) + const lastPointer = useRef({ x: 0, y: 0 }) + + // 合并重力和触摸数据(含摩擦力系统) const updateState = useCallback(() => { + const now = Date.now() const gyro = gyroRef.current const touch = touchRef.current @@ -79,12 +91,38 @@ export function useCardInteraction(options: CardInteractionOptions = {}) { x = Math.max(-1, Math.min(1, x)) y = Math.max(-1, Math.min(1, y)) + // 摩擦力系统:计算运动幅度(相对于上一个稳定位置) + const delta = Math.hypot(x - lastPointer.current.x, y - lastPointer.current.y) + + if (frictionPhase.current === 'static') { + // 静止阶段:只有大幅运动或触摸才唤醒 + if (delta > wakeThreshold || touch.active) { + frictionPhase.current = 'dynamic' + lastActiveTime.current = now + lastPointer.current = { x, y } + } else { + // 过滤微弱运动,保持静止(不更新 state,不更新 lastPointer) + return + } + } else { + // 动态阶段:检查是否该进入静止 + if (delta > wakeThreshold || touch.active) { + lastActiveTime.current = now + lastPointer.current = { x, y } + } else if (now - lastActiveTime.current > idleTimeout) { + frictionPhase.current = 'static' + // 进入静止时:保持当前位置不变(冻结),不再更新 + // lastPointer 也保持不变,用于检测唤醒 + return + } + } + setState({ pointerX: x, pointerY: y, isActive: active, }) - }, [applyGyroCurve, gyroStrength, touchStrength, enableGyro, enableTouch]) + }, [applyGyroCurve, gyroStrength, touchStrength, enableGyro, enableTouch, idleTimeout, wakeThreshold]) // 处理重力感应 useEffect(() => {