Skip to content

渲染器 ​

渲染器(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.tsHoloCard 类:DOM 结构、视差、弹簧驱动的交互、炫光角度模型、陀螺仪接管
card.css结构样式、逐层箔面遮罩、整卡炫光与高光、「减少动态效果」
foils.css箔面配方
highlight.ts高光保护遮罩
spring.ts弹簧积分
gyro.ts设备倾斜 → 指针位置,基准自动回正
textures.ts颗粒(grain)与闪粉(glitter)纹理的程序化生成

使用 ​

ts
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 结构 ​

text
.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
sunpillarV 卡日柱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 会引入透视缩放,导致各层尺寸对不上。渲染器改为每层各自做二维位移,位移量正比于该层视差系数与振幅:

css
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 两种设置方式),上游调好的参数可以原样使用。

场景stiffnessdamping说明
跟随指针0.0660.25上游 springInteractSettings
松手回正0.010.06指针离开 500 ms 后开始,soft 启动持续 1 秒,卡片先停顿再缓缓归位
陀螺仪驱动0.250.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),越靠边缘箔面越亮

指针坐标取自不旋转的根节点矩形;旋转中元素的包围盒会随之变形,用它计算坐标会自激。鼠标抬起不视为离开,触摸抬起才视为离开。

整卡炫光的角度模型 ​

整卡炫光是角度事件:由当前转角推出卡面法线,与「能把光源反射进眼睛」的那个朝向做点积,宽、窄两瓣叠加:

text
强度 = 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 同时停用弹簧循环,指针停止即直接落位,不产生过冲。该偏好每次实时读取,修改系统设置后无需刷新页面。