Visitar URL original
Paper | Playground

Paper

OGLとGLSLで紙繊維、光、表裏の陰影を描き、四辺から湾曲して捲れるCotton紙の質感UI Lab。

Lift the edge.

How it works

紙の質感と、掴んだ場所から連続して湾曲する動きをWebGLで描くUI Labです。描画にはOGLを使い、Canvas 2Dで作った紙面を一枚のTextureとして高分割のPlaneへ貼っています。

紙の外側3分の1なら、辺や角の決められた点に限らずドラッグを始められます。ドラッグ中も開始点と現在のPointer位置から折る方向を計算し直すため、右下から右上へ移動したあと、そのまま左上へ動かしてもカールがPointerへ追従します。

紙面を一枚のTextureにする

地色は純白ではなく、少し黄味のある #f2eee4 にしました。Canvas 2Dへ地色、緩い照明、細い繊維、文字を順に描き、そのCanvasをOGLの Texture へ渡します。

const canvas = document.createElement("canvas");
canvas.width = 1024;
canvas.height = Math.round(1024 / aspect);
 
const context = canvas.getContext("2d");
context.fillStyle = "#f2eee4";
context.fillRect(0, 0, canvas.width, canvas.height);
 
const texture = new Texture(gl, {
  image: canvas,
  anisotropy: isMobile ? 2 : 4,
  generateMipmaps: true,
  minFilter: gl.LINEAR_MIPMAP_LINEAR,
});

紙面の文字は見出し一つだけに留めています。文字が多いと、紙を捲ったときに内容の変形が質感より先に目へ入ります。背面には同じ地色の紙を少しずつ回転させて二枚置き、一枚目が持ち上がった場所から紙の重なりが見えるようにしました。

外側3分の1を操作領域にする

操作領域は上下左右それぞれの外側3分の1です。四つの領域のどれかに入っていればドラッグを開始するため、辺の中央から四隅まで一続きに触れます。

const insideOuterThird =
  point.x <= size.width / 3 ||
  point.x >= size.width * (2 / 3) ||
  point.y <= size.height / 3 ||
  point.y >= size.height * (2 / 3);

Pointerが動き始めたら、開始点から現在位置へ向かう単位ベクトルをカールの方向に使います。同じ辺の途中から斜めへ引いた場合も、その角度のまま曲がります。紙の外側へ引いたときだけ中心方向の初期値へ戻し、紙面全体が裏返る変形を避けています。

掴んだ点を基準にPlaneを曲げる

紙はモバイルで44×56、デスクトップで64×72に分割した一枚のPlaneです。ドラッグ開始点をPlane上の座標へ変換し、Pointerの移動距離からカールの位置を決めます。

const projectedSpan = Math.abs(direction.x) * pageAspect + Math.abs(direction.y);
const maximumFlapSize = projectedSpan / 3;
const flapSize = Math.min(maximumFlapSize, travel * 0.5);
 
uniforms.uFlapSize.value = flapSize;
uniforms.uHinge.value = dot(anchor, direction) + (travel + flapSize) * 0.5;

direction はPointerが移動するたびに更新し、anchor は指を置いた場所に固定します。カールする円弧は投影幅の3分の1を上限とし、それを越えた紙は曲面の接線方向へ伸ばします。uHinge を移動距離と円弧長の中間へ置くことで、掴んだ頂点がPointerの現在位置まで移動します。

頂点シェーダーでは折り返し線から各頂点までの距離を求め、円周上の位置へ置き直します。sin() が紙面上の移動、1 - cos() が手前へ持ち上がる量です。

float bendDistance = min(distance, uFlapSize);
float tailDistance = max(distance - uFlapSize, 0.0);
float foldAmount = bendDistance / max(uFlapSize, EPSILON);
float localAngle = totalAngle * foldAmount;
float radius = uFlapSize / max(totalAngle, EPSILON);
 
float curvedDistance = sin(localAngle) * radius;
float lift = (1.0 - cos(localAngle)) * radius;
 
local += uDirection * (
  distance - curvedDistance - tailDistance * cos(localAngle)
);
lift += tailDistance * sin(localAngle);

掴んだ場所と投影方向によっては、折り返し線より外側の距離が3分の1を超えます。余った部分を tailDistance としてカール終端の接線方向へ伸ばすことで、頂点が折り返し線へ集まって直線になるのを防いでいます。対角方向では直交する軸へ小さなbowも加え、円筒のように均一な曲面になるのを少し崩しました。

Cotton紙の光を作る

fragment shaderでは変形後の法線からdiffuseを計算し、specularは紙の輪郭が消えない程度まで落としています。Cotton紙は光沢紙ではないため、fresnelによる白い縁取りは加えず、繊維の凹凸を拡散光で見せます。

裏面は gl_FrontFacing で判定し、少し暗いCotton色へ切り替えます。表の文字を反転して薄く混ぜると、インクが紙を透けているような裏面になります。粒子と長い繊維はfragment shader側でも加え、拡大時にTextureの周期が目立たないようにしています。

ドラッグしている間だけ描画する

紙の操作面には touch-action: none を指定し、Pointer Captureはドラッグ中だけ使います。pointerup と pointercancel の両方で解除するため、指が紙の外へ出ても操作が途切れず、離したあとはページの操作へ戻ります。

常時requestAnimationFrameを回さず、ドラッグ、離したあとの短い復帰、Resizeの間だけ再描画します。

WebGL contextを作れない場合とcontext lost時には、同じ地色と最小限の文字を持つ静的な紙面へ戻します。prefers-reduced-motion: reduce では指に追従する変形だけを残し、指を離したあとの補間は行いません。