渲染器
渲染器(src/renderer/)接收一个 LayerSet(manifest 加每层图片),在页面中生成一张可交互的闪卡。它不依赖任何框架,也不依赖分层算法:手工切好的图层同样可以渲染。站点前端、@holocard/player 与双盲对比页使用同一份渲染器。
箔面配方与交互手感(指针到转角、高光、背景位移的映射,弹簧参数,松手回正的节奏)移植自 pokemon-cards-css(Simon Goellner,GPL-3.0)。视差、逐层箔面遮罩、整卡炫光的角度模型、高光保护与陀螺仪回正是本项目的部分。
一张卡的组成
结构对应真实闪卡的印法:底材是箔,主体用不透明油墨覆盖所以哑光,背景不覆盖所以闪光。
| 组成 | 作用范围 | 随视差移动 | 出现时机 | manifest 字段 |
|---|---|---|---|---|
| 景深层 | 各层自己的画面 | 是,各层系数不同 | 始终 | layers[].parallax |
| 每层的箔面 | 只在该层的 alpha 形状内 | 是,跟随所在层 | 指针悬停即亮 | layers[].foil |
| 整卡炫光 | 整张卡面 | 遮罩跟随所在视差组,光本身不动 | 只在特定倾角浮现 | effects.halo |
| 整卡高光 | 整张卡面 | 同上 | 跟随指针 | effects.glare |
文件
| 文件 | 职责 |
|---|---|
card.ts | HoloCard 类:DOM 结构、视差、弹簧驱动的交互、炫光角度模型、陀螺仪接管 |
card.css | 结构样式、逐层箔面遮罩、整卡炫光与高光、「减少动态效果」 |
foils.css | 箔面配方 |
highlight.ts | 高光保护遮罩 |
spring.ts | 弹簧积分 |
gyro.ts | 设备倾斜 → 指针位置,基准自动回正 |
textures.ts | 颗粒(grain)与闪粉(glitter)纹理的程序化生成 |
使用
import { HoloCard } from './renderer/card';
import { loadLayerSet } from './format/io';
const card = new HoloCard(document.querySelector('#card')!, { amplitude: 0.02 });
card.setLayerSet(await loadLayerSet('/api/layers/<id>'));| 选项 / 方法 | 说明 |
|---|---|
amplitude | 视差振幅:最近层与最远层之间的最大相对位移占卡宽的比例,默认 0.02;0 关闭视差。作者配置的上限为 0.16(PARALLAX_MAX) |
tiltScale | 倾斜幅度倍率,默认 1(最大转角约 14.3°) |
setLayerSet(set) | 挂载或替换一组层。先校验层数与图片数一致,再拆除旧卡 |
setLayerFoil(index, foil) / setHalo(halo) / setOptions(patch) | 运行时修改箔面、炫光、振幅,立即生效,不重建 DOM |
setPose({x, y} | null) | 不经动画直接摆到指针位于卡面 (x, y) 百分比处的姿态;null 回到静止。用于分享图、导出动图与录制脚本 |
preview() | 平滑转到炫光最亮的角度,停留 1.2 秒后回正。调参面板拖动滑块时调用 |
enableGyro() | 跟随设备倾斜。iOS 需由调用方先在用户手势中取得 DeviceOrientationEvent.requestPermission() 授权 |
ready | 高光保护遮罩全部生效后 resolve。截图前需等待 |
destroy() | 移除 DOM、监听器,并释放全部 object URL |
渲染器本身不读取 effects.parallax;由调用方按它设置 amplitude(src/player 中开关关闭时振幅取 0)。
DOM 结构
.hc 根节点:--hc-aspect、--hc-amp、--hc-scale 与指针变量
└ .hc__translater perspective: 600px
└ .hc__rotator 3D 旋转,接收指针事件
└ .hc__stack
├ .hc__plane 每层一个:平移 + 放大
│ ├ .hc__art 该层画面
│ └ .hc__shine 该层箔面,data-foil 指定配方,--hc-mask 为遮罩
└ .hc__fx 每个视差组一个,压在该组之上
├ .hc__halo 整卡炫光
└ .hc__glare 整卡高光(effects.glare 为 true 时)逐层箔面
每层的画面与箔面包在同一个 .hc__plane 中,位移加在 plane 上,有两个作用:
- 箔面天然随所在层移动,不会与画面错位。
transform使 plane 成为独立的层叠上下文,箔面的color-dodge只与本层画面混合,不会透下去染到更远的层。
箔面遮罩(--hc-mask)就是该层图片本身:箔只出现在该层的 alpha 形状内。上游项目需要作者为每张卡手工准备一张遮罩图,HoloCard 的分层结果即自动生成的遮罩。
foil.type 有四种配方,foil.intensity(0..1)控制强度:
| 类型 | 效果 | 对应上游配方 |
|---|---|---|
none | 哑光,不上箔 | — |
holo | 经典闪卡 | regular-holo.css |
sunpillar | V 卡日柱 | v-regular.css |
rainbow | 彩虹闪粉 | rainbow-holo.css |
foils.css 中的渐变、混合模式与滤镜数值与上游一致,改动只有四处:选择器改为 .hc__shine[data-foil=…];去掉上游限制在插画窗口内的 clip-path;rainbow 省略依赖每张卡专属蚀刻图的 :before;sunpillar 斜纹层的位置由 -x 改为 100% - x,去掉一道竖直接缝。CSS 变量名(--pointer-x、--background-x、--card-opacity 等)与上游保持一致,移植其他配方时可直接复制。
颗粒与闪粉纹理由 textures.ts 用固定种子(mulberry32)在运行时生成,仓库中没有二进制素材。纹理生成前箔面照常工作,只是暂时没有颗粒与闪粉;不支持 OffscreenCanvas 的环境退化为纯渐变。
视差
二维位移代替 translateZ
translateZ 会引入透视缩放,导致各层尺寸对不上。渲染器改为每层各自做二维位移,位移量正比于该层视差系数与振幅:
transform:
translate3d(calc(var(--hc-nx) * var(--hc-parallax) * var(--hc-amp) * -1),
calc(var(--hc-ny) * var(--hc-parallax) * var(--hc-amp) * -1), 0)
scale(var(--hc-scale));--hc-nx、--hc-ny 是经过弹簧的指针位置(-1..1,钳位以防弹簧过冲),因此层的滑动与转卡手感一致。近层的位移方向与指针相反:指针在右侧时卡片右缘抬起,相当于观察者站到右边,近处物体相对远处向左移动。
焦平面在中间
焦平面取最远层与最近层视差的中点,每层的 --hc-parallax 为其视差减去焦平面:近处向外、远处向内,同样的立体感下每层只需移动一半。默认振幅下主体与背景最多错开 10% 卡宽。所有层视差相同(整张图是一个刚体)时焦平面落在它们上面,卡面不发生视差。
统一放大倍数
位移会在另一侧边缘露白,因此需要放大补偿,倍数为 1 + 2 × maxOffset × amplitude(maxOffset 为各层相对焦平面的最大偏移)。所有层必须共用同一个倍数:放大绕卡片中心进行,各层按各自位移放大时,静止状态下主体会比它在背景中留下的空缺大一圈,层与层接不上。共用倍数后,静止时整张卡是原图的等比放大,各层严丝合缝。
交互
弹簧
转角、高光、背景位移各由一根弹簧驱动(spring.ts),三根弹簧共用一个 requestAnimationFrame 循环,全部静止后自动停止。弹簧语义与 svelte/motion 一致(stiffness、damping、precision,以及 soft、hard 两种设置方式),上游调好的参数可以原样使用。
| 场景 | stiffness | damping | 说明 |
|---|---|---|---|
| 跟随指针 | 0.066 | 0.25 | 上游 springInteractSettings |
| 松手回正 | 0.01 | 0.06 | 指针离开 500 ms 后开始,soft 启动持续 1 秒,卡片先停顿再缓缓归位 |
| 陀螺仪驱动 | 0.25 | 0.85 | 接近临界阻尼。传感器读数本身平滑,再套跟手的软弹簧会明显滞后 |
每帧的时间步长以 1/60 秒为单位,上限 3 帧,避免页面卡顿或切回前台后一步过大把卡片甩飞;因此切回前台时不需要复位。
指针映射
| 量 | 映射 |
|---|---|
| 转角 | rotateY(-(x − 50) / 3.5)、rotateX((y − 50) / 3.5),最大约 ±14.3°,指针所在一侧朝观察者抬起 |
| 背景位移 | x 收窄到 37%~63%,y 收窄到 33%~67%,箔面花纹只是微微游动 |
--pointer-from-center | 指针离卡片中心的距离(0..1),越靠边缘箔面越亮 |
指针坐标取自不旋转的根节点矩形;旋转中元素的包围盒会随之变形,用它计算坐标会自激。鼠标抬起不视为离开,触摸抬起才视为离开。
整卡炫光的角度模型
整卡炫光是角度事件:由当前转角推出卡面法线,与「能把光源反射进眼睛」的那个朝向做点积,宽、窄两瓣叠加:
强度 = 0.2 × dot^(sharpness / 5) + 0.8 × dot^sharpness宽瓣提供底光,窄瓣提供主峰,既不会全程不可见,又保留转到特定角度才浮现的观感。光源方向 effects.halo.light.peakAt 默认 [0.7, -0.7](右上方),sharpness 默认 120(倾角只有十几度,法线夹角很小,锐度需要很高才能切出角度窗口),intensity 默认 0.35。默认参数下强度在静止时约 0.12、峰值倾角时 1.00、反方向约 0.01。法线计算与 CSS 的 rotateY(rx) rotateX(ry) 严格对应,否则炫光角度会与肉眼看到的姿态不一致。
炫光的渐变色标位置随背景位移横扫卡面,移动的是色标而不是背景块,避免平铺接缝。
高光保护
箔面大多使用 color-dodge(底色 ÷ (1 − 箔色)),整卡高光使用 overlay,亮部叠加后会被推到纯白,白衣服、白毛的纹理随之消失。因此每层箔面、整卡炫光与高光的遮罩都按画面亮度(Rec.709)打折:亮度 0.6 以下不变,往上用 smoothstep 平滑减弱,到纯白处保留 0.2(highlight.ts)。暗部与中间调的效果不变。
遮罩先直接使用层图,保护遮罩异步计算完成后再替换;静止时箔面本来透明,替换过程不可见。截图前需等待 ready。
炫光与高光按视差组分层
视差相同的相邻层构成一组。每组上方各压一份炫光与高光(.hc__fx),其遮罩只按该组的画面计算亮部保护,并随该组平移、放大;更近的组覆盖下方那份,每个像素最终使用其最上方一组的炫光。渐变本身不动,仍是整卡一片光。若整卡只用一张静止遮罩,层移动后遮罩中的轮廓会与人物错开,在人物边缘形成一道白带。
遮罩尺寸放大为 --hc-scale 倍后,遮罩位置 50% + n·s / (2·maxOffset)(s 为该组视差减焦平面)恰好对应 plane 的位移,与振幅无关;推导见 card.ts 注释。
陀螺仪
gyro.ts 的 TiltTracker 把设备方向(beta、gamma)换算为卡面上的指针位置,纯计算、不访问 DOM,由 scripts/verify-gyro.mjs 验证。
- 量程:左右倾斜 16°、前后倾斜 18° 时指针到达卡面边缘,沿用上游 orientation 版本。方向与上游相反:卡片与手机同向转动,如同手中真的拿着这张卡。
- 自动回正:基准姿态以时间常数 3 秒做指数跟随(按实际时间计算,与事件频率无关),而不是固定为第一次读数。持续静止时卡片几秒内回正;只有转动手机的过程产生偏转;传感器缓慢漂移不会累积偏转。
- 死区:偏转小于 1° 视为未动,卡片回正,动画循环停止,避免持握时一直以 60 帧重绘。
- 读数跳变:与基准相差超过 60° 时(手机越过竖直位置,
beta/gamma整段跳变)直接重置基准。 - 横屏:按
screen.orientation.angle把设备坐标换算为屏幕坐标,左右与前后对调。
在 HoloCard 中,手指在卡面上或 preview() 展示期间,陀螺仪让出控制,手指离开 1 秒后再接管;让出期间基准仍照常跟随,接回时不会跳动。页面不可见或用户开启「减少动态效果」时忽略方向事件。
减少动态效果
prefers-reduced-motion: reduce 下保留箔面,去掉几何运动:.hc、.hc__rotator、.hc__plane 的 transform 被锁定,炫光遮罩恢复原尺寸对齐;箔面的 background-position 仍随指针变化,表现为原地变色的微光。card.ts 同时停用弹簧循环,指针停止即直接落位,不产生过冲。该偏好每次实时读取,修改系统设置后无需刷新页面。