ビジュアル

Three.jsとWebGPUで作る、マウスと連動するスクロール追従ライティング演出

投稿日2026/07/21

更新日2026/7/21

テキストの背後で光がマウスに追従し、スクロールするたびに色が変わっていく――そんな繊細なライティング演出を、Three.jsのWebGPUレンダラーで実現したデモです。

白い平面にRectAreaLightを当て、マウスの位置に応じて光源が移動することで、テキストの背景にやわらかい光の表情が生まれます。

スクロール量に連動してライトのHSL色相が変化し、読み進めるにつれて光の色がグラデーションのように移り変わります。

TSL(Three Shading Language)によるカスタムレンダーパイプラインで、明るい部分を透過させることで背景のHTMLと自然に馴染む仕上がりになっています。

Code コード

<div id="kumonosu-main-container">
	<div id="kumonosu-wrapper">
		<h1 class="kumonosu-content"> 吾輩は猫である。名前はまだ無い。どこで生れたかとんと見当がつかぬ。何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。吾輩はここで始めて人間というものを見た。しかもあとで聞くとそれは書生という人間中で一番獰悪な種族であったそうだ。この書生というのは時々我々を捕えて煮て食うという話である。しかしその当時は何という考もなかったから別段恐ろしいとも思わなかった。ただ彼の掌に載せられてスーと持ち上げられた時、何だかフワフワした感じがあったばかりである。掌の上で少し落ちついて書生の顔を見たのがいわゆる人間というものの見始であろう。この時妙なものだと思った感じが今でも残っている。第一毛をもって装飾されべきはずの顔がつるつるしてまるで薬缶だ。その後猫にもだいぶ逢ったがこんな片輪には一度も出会わした事がない。のみならず顔の真中があまりに突起している。そうしてその穴の中から時々ぷうぷうと煙を吹く。どうも咽せぽくて実に弱った。これが人間の飲む煙草というものである事はようやくこの頃知った。 <br><br> この書生の掌の裏でしばらくはよい心持に坐っておったが、しばらくすると非常な速力で運転し始めた。書生が動くのか自分だけが動くのか分らないが無暗に眼が廻る。胸が悪くなる。到底助からないと思っていると、どさりと音がして眼から火が出た。それまでは記憶しているがあとはどうなったかいくら考え出そうとしても分らない。 ふと気が付いて見ると書生はいない。たくさんおった兄弟が一匹も見えぬ。肝心の母親さえ姿を隠してしまった。その上今までいた所とはるかに違って無暗に明るい。眼を明いていられぬくらいだ。始終容子がおかしいと、のろのろ這い出して見ると非常に痛い。吾輩は藁の上から急に笹原の中へ棄てられたのである。 </h1>
	</div>
</div>
body {
	margin: 0;
	background-color: #fff;
}
#kumonosu-stage {
	display: block;
	width: 100%;
	height: 100vh;
	position: fixed;
	top: 0;
	left: 0;
	pointer-events: none;
	z-index: 1;
}
.kumonosu-content {
	position: relative;
	z-index: 2;
	margin: 0;
	font-size: 2em;
	line-height: 1.6;
	padding: 1em;
	color: #000;
	pointer-events: none;
}
#kumonosu-wrapper {
	width: 100%;
	min-height: 100vh;
}
// esm.sh を利用して依存関係を自動解決
import * as THREE from 'https://esm.sh/three@0.184.0/webgpu';
import {
	TSL as $
} from 'https://esm.sh/three@0.184.0/webgpu';
import {
	RectAreaLightTexturesLib
} from 'https://esm.sh/three@0.184.0/examples/jsm/lights/RectAreaLightTexturesLib.js';
THREE.RectAreaLightNode.setLTC( RectAreaLightTexturesLib.init( ) );
const renderer = await new THREE.WebGPURenderer( ).init( );
renderer.domElement.id = 'kumonosu-stage';
// main-containerの先頭にCanvasを挿入
const container = document.getElementById( 'kumonosu-main-container' );
container.prepend( renderer.domElement );
const scene = new THREE.Scene( );
const camera = new THREE.PerspectiveCamera( );
camera.position.set( 0, 0, 2 );
const mat = new THREE.MeshPhysicalNodeMaterial( {
	roughness: 0.2,
	metalness: 0.0
} );
const floor_mesh = new THREE.Mesh( new THREE.PlaneGeometry( 100, 100 ), mat );
scene.add( floor_mesh );
const light0_tall = 0.777;
const light0 = new THREE.RectAreaLight( 'white', 0, light0_tall, 0 );
scene.add( light0 );
const light_bulb_mat = new THREE.MeshBasicMaterial( {
	color: 0xffffff
} );
const light_bulb = new THREE.Mesh( new THREE.PlaneGeometry( 1, 1 ), light_bulb_mat );
scene.add( light_bulb );
const rp = new THREE.RenderPipeline( renderer );
const scene_pass = $.pass( scene, camera );
rp.outputNode = $.vec4( scene_pass.rgb, $.mx_rgbtohsv( scene_pass.rgb ).z.oneMinus( ).pow( 16 ).mul( 0.9 ) );
renderer.setAnimationLoop( ( ) => rp.render( ) );
const light0_min_height = 0.3;
const light0_max_height = 0.5;
const light0_min_radius = 0.3;
const light0_min_intensity = 1.5;
const light0_max_intensity = 3.0;
const raycaster = new THREE.Raycaster( );

function handle_pointer_move ( x, y ) {
	raycaster.setFromCamera( new THREE.Vector2( x, y ), camera );
	const intersections = raycaster.intersectObject( floor_mesh );
	for ( const i of intersections ) {
		if ( i.object === floor_mesh ) {
			const a = -Math.atan2( i.point.y, i.point.x ) + Math.PI / 2;
			const r = Math.max( light0_min_radius, Math.hypot( i.point.x, i.point.y ) ) + 0.2;
			const px = r * Math.sin( a );
			const py = r * Math.cos( a );
			const pz = light0.width / 2;
			light0.position.set( px, py, pz );
			light0.lookAt( 0, 0, pz );
			light_bulb.position.copy( light0.position );
			light_bulb.rotation.copy( light0.rotation );
			light_bulb.scale.set( light0.width, light0.height, 1 );
			const dist = Math.hypot( x, y );
			light0.height = Math.min( light0_max_height, Math.max( light0_min_height, dist ) );
			light0.intensity = light0_min_intensity + Math.min( light0_max_intensity - light0_min_intensity, dist );
		}
	}
}
document.addEventListener( 'pointermove', ( e ) => {
	const stage = document.getElementById( 'kumonosu-stage' );
	if ( !stage ) return;
	const rect = stage.getBoundingClientRect( );
	const x = ( e.clientX / rect.width ) * 2 - 1;
	const y = ( 1 - ( e.clientY ) / rect.height ) * 2 - 1;
	handle_pointer_move( x, y );
} );

function update_light0_color ( ) {
	const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
	const y_ratio = scrollHeight > 0 ? Math.min( 1.0, window.scrollY / scrollHeight ) : 0;
	light0.color.setHSL( y_ratio, 0.5, 0.5 );
	light_bulb_mat.color.setHSL( y_ratio, 0.5, 0.8 );
}
window.addEventListener( 'scroll', ( ) => update_light0_color( ) );
setTimeout( ( ) => {
	handle_pointer_move( 0.2, -0.2 );
	update_light0_color( );
}, 150 );
window.addEventListener( 'resize', ( ) => {
	const width = window.innerWidth;
	const height = window.innerHeight;
	renderer.setPixelRatio( window.devicePixelRatio );
	renderer.setSize( width, height, false );
	camera.aspect = width / height;
	camera.updateProjectionMatrix( );
} );
window.dispatchEvent( new Event( 'resize' ) );

Explanation 詳しい説明

仕様

このデモは、Three.jsのWebGPURendererを使い、平面上のRectAreaLightをマウスとスクロールに連動させるライティング演出です。3DキャンバスとHTMLテキストを重ね合わせる構成になっています。

主な機能は以下のとおりです。

  • WebGPURenderer: Three.jsのWebGPU対応レンダラーを使用しています。従来のWebGLRendererより高効率なGPU処理が可能です。
  • RectAreaLight: 矩形面光源が平面(PlaneGeometry)を照らし、MeshPhysicalNodeMaterial(roughness: 0.2)のやわらかい質感と組み合わせてリアルなライティングを表現します。
  • マウス追従: pointermoveイベントでRaycasterを使い、マウス位置に応じて光源の位置・高さ・強度がリアルタイムに変化します。光源は常に中心を向くようlookAtで制御されています。
  • スクロール連動の色変化: ページのスクロール量(0〜100%)をHSL色相(0〜1)にマッピングし、ライトの色が読み進めるにつれて変化します。
  • TSLカスタムパイプライン: Three Shading Language(TSL)でRenderPipelineを構築し、出力のアルファ値を輝度から算出しています。明るい部分(白い背景)が透過し、照らされた部分だけが浮かび上がるような合成効果を実現しています。
  • ライトバルブ表示: 光源の位置に白い平面メッシュを配置し、光源自体の存在を視覚的に示しています。
  • テキストオーバーレイ: Canvasをpointer-events: noneで最背面に配置し、HTMLのテキストが上に重なるレイヤー構成です。

カスタマイズ

  • テキストの変更: HTMLの.kumonosu-content内のテキストを書き換えれば、任意の文章に変更できます。テキスト量が変わるとスクロール範囲も変わり、色変化の速さに影響します。
  • ライトの色: light0.color.setHSL(y_ratio, 0.5, 0.5)の彩度(0.5)と明度(0.5)を変更すると、色の鮮やかさや明るさを調整できます。
  • ライトの強度: light0_min_intensitylight0_max_intensityを変更すると、光の強さの範囲を調整できます。
  • ライトの高さ: light0_min_heightlight0_max_heightで光源の矩形サイズの範囲を調整でき、光のやわらかさが変わります。
  • マテリアルの質感: roughnessを大きくするとマットな質感、小さくするとツヤのある質感になります。metalnessを上げると金属的な反射が加わります。
  • 背景色: CSSのbodybackgroundを変更すると、透過合成後の見た目が変わります。白以外の背景でも動作しますが、透過の仕組みが輝度ベースのため暗い背景では効果が異なります。
  • テキストのスタイル: CSSの.kumonosu-contentでフォントサイズ・行間・色を自由に変更できます。

注意点

  • WebGPU対応ブラウザ限定: WebGPURendererを使用しているため、WebGPUに対応したブラウザ(Chrome 113以降、Edge 113以降など)が必要です。Safari やFirefoxでは対応状況が限定的なため、動作しない場合があります。
  • Three.jsの読み込み: esm.sh経由でThree.js v0.184.0のWebGPUビルドを読み込んでいます。本番環境ではバージョン固定やローカルホスティングを推奨します。
  • RectAreaLightTexturesLib: RectAreaLightの正確なライティングにはLTCテクスチャの初期化が必要で、RectAreaLightTexturesLib.init()を呼び出しています。この処理が失敗するとライティングが正しく表示されません。
  • モバイル対応: pointermoveイベントを使用しているためタッチ操作にも反応しますが、タッチ中のみイベントが発火するため、PCのマウスホバーほどのなめらかな追従にはなりません。
  • パフォーマンス: WebGPURendererは対応環境ではWebGLより高効率ですが、レンダーパイプラインのカスタムノード処理が加わるため、古いGPUでは負荷がかかる場合があります。
  • トップレベルawait: await new THREE.WebGPURenderer().init()をトップレベルで使用しているため、type="module"が必須です。
  • スクロールとキャンバスの関係: キャンバスはposition: fixedで固定されており、スクロールはHTMLテキスト側で発生します。キャンバス自体はスクロールしません。