この記事は、2023年traP新歓ブログリレー28日目の記事です。
こんにちは。20Bの@Rasです。東工大に合格された方はおめでとうございます。今回のブログリレーで会うのは2回目ですね。
少し前にスリットスキャン (Slit-scan)という技術に興味を持ちました。スリットスキャン自体はかなり前からあるらしいんですが、興味を持つきっかけになったのはこの動画です。
今回はReactとThree.jsを使って簡単なスリットスキャンを実装してみました。
ちなみにAdobe After Effectsみたいなソフトを使うと簡単に実現できるらしいです。
参考記事
- After Effectsで昼夜グラデーション画像を作ろう | 東京工業大学デジタル創作同好会traP
- 【React Three Fiber】オブジェクトのClippingと断面の描画 - Qiita
- スリットスキャンの応用 | 麦 Baku (baku89.com)
- SUPER SLIT SCAN (kitasenjudesign.com)
- SLITSCAN RESEARCH (scrapbox.io)
- slit-scan - lookdev (scrapbox.io)
作ったもの
結構重いのでスマホとかだと見れないかもです。
箱に動画が映し出され、パラメータを調整することで断面を操作することができます。
カメラでのストリーミングにも対応しているので自分の顔をぐにゃぐにゃしてみてください。
(このサイトではカメラ映像がサーバーに送られることはないので安心してね)。
スリットスキャンってなに
こういうやつです。
もっと滑らかなやつだとこういうやつ。
背景は動いていないのに人とドアだけが動いてるのとか興味深いですよね。
しくみ
まず、動画の各フレームを一列に並べたものを3次元空間に投影します。
次に、並べたフレームを時間軸からずれるように斜めにカットします。
カットしてできた断面を正面から見ると、1枚の画像なのに複数の時間軸を持った不思議な画像になります。
(背景などの静止した物体は時間軸が変わっても動かないため、動いている物体のみが波打って見えます)
そしてフレームの列を時間経過で動かすことで、節の最初に紹介した動画のような不思議な動画を作ることができます。
もちろん断面は四角形である必要はなく、自由に断面を作ることでさらに応用的なフレームを作ることができます。
slitscan3dでは、断面を動かすことで縦だけでなく横や斜めに分割されたスリットスキャンを実現できるようにしました。
実装
再度レポジトリを貼ります。
タイトルにもあるように、今回はReactとThree.jsを使ってslitscan3dを作りました。
Reactはほぼ未経験、Three.jsについては全くの未経験だったのですが、文法や仕様を理解する良い経験になりました。
R3Fはいいぞ
生のThree.jsをReact上で触るのは結構面倒くさい(らしい)のですが、Three.jsをReactのコンポーネント風に書けるReact Three Fiber(R3F)を使ったことでかなり楽に書くことができました。
R3F公式ドキュメントに載っている簡単な例を見ても、R3Fを使うことでシンプルに記述できることが分かると思います。
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000)
const renderer = new THREE.WebGLRenderer()
renderer.setSize(width, height)
document.querySelector('#canvas-container').appendChild(renderer.domElement)
const mesh = new THREE.Mesh()
mesh.geometry = new THREE.BoxGeometry()
mesh.material = new THREE.MeshStandardMaterial()
scene.add(mesh)
function animate() {
requestAnimationFrame(animate)
renderer.render(scene, camera)
}
animate()
<Canvas>
<mesh>
<boxGeometry />
<meshStandardMaterial />
</mesh>
</Canvas>
実装の概要
slitscan3dにおけるスリットスキャンの描画処理は主に src/components/SlitScan/SlitScanGroup.tsx に書かれています。
与えられた動画を <canvas>
に描画し、一定時間ごとのキャプチャをallTextures
に保存します。
const videoCanvas = useMemo(() => {
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
return canvas
}, [video])
const videoCtx = videoCanvas.getContext('2d', { willReadFrequently: true })
if (videoIsValid()) {
videoCtx?.drawImage(video, 0, 0, video.videoWidth, video.videoHeight)
}
const createFrameLoop = useCallback(() => {
if (videoIsValid()) {
allTextures.push(new CanvasTexture(videoCanvas))
setAllTextures(allTextures)
}
}, [video])
useAnimationFrame(createFrameLoop)
ある程度フレームが揃ってきたらフレームの列を回転させます。
// callbackを毎フレーム呼ぶR3Fのhook
useFrame(() => {
// ...
frameIndex.current = (frameIndex.current + 1) % allTextures.length
setTextures(textures.map((_, i) => allTextures[(frameIndex.current + i) % allTextures.length]))
})
最後に各フレームの描画位置を少しずつずらしながら<boxGeometry>
に描画することでアニメーションを作成することができます。
また、clippingPlanes
を指定してオブジェクトをクリッピングすることで、スリットスキャンの特徴である断面を再現することができます。
// clip the front of the extra drawn box
const additionalClipPlane = useMemo(() => new Plane(new Vector3(0, 0, -1), Math.ceil(depth / 2)), [depth])
return (
<group>
{textures.map((texture, i) => (
<mesh key={texture.id} castShadow position={[0, 0, i * (depth / textures.length)]}>
<boxGeometry args={[width, height, depth]} />
<meshStandardMaterial
map={texture}
clippingPlanes={[additionalClipPlane, ...clipPlanes]}
clipShadows={true}
side={DoubleSide}
/>
</mesh>
))}
</group>
)
おわりに
まだReact hooksが正しく使えてなかったりslitscan3dのサイト自体がかなり重かったりと改善点はありますが、これまでやったことがないことをやるのは楽しかったです。
traPには基本的にどの分野にも先駆者がいるので気軽に質問できる環境が整っています。気になった方は是非部室まで!
明日は@ikura-hamu、@jippoの記事です。お楽しみに!