HTML / CSS / JS
ビジュアル
2026/07/21
2026/7/21
テキストの背後で光がマウスに追従し、スクロールするたびに色が変わっていく――そんな繊細なライティング演出を、Three.jsのWebGPUレンダラーで実現したデモです。
白い平面にRectAreaLightを当て、マウスの位置に応じて光源が移動することで、テキストの背景にやわらかい光の表情が生まれます。
スクロール量に連動してライトのHSL色相が変化し、読み進めるにつれて光の色がグラデーションのように移り変わります。
TSL(Three Shading Language)によるカスタムレンダーパイプラインで、明るい部分を透過させることで背景のHTMLと自然に馴染む仕上がりになっています。
<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' ) );
このデモは、Three.jsのWebGPURendererを使い、平面上のRectAreaLightをマウスとスクロールに連動させるライティング演出です。3DキャンバスとHTMLテキストを重ね合わせる構成になっています。
主な機能は以下のとおりです。
PlaneGeometry)を照らし、MeshPhysicalNodeMaterial(roughness: 0.2)のやわらかい質感と組み合わせてリアルなライティングを表現します。pointermoveイベントでRaycasterを使い、マウス位置に応じて光源の位置・高さ・強度がリアルタイムに変化します。光源は常に中心を向くようlookAtで制御されています。RenderPipelineを構築し、出力のアルファ値を輝度から算出しています。明るい部分(白い背景)が透過し、照らされた部分だけが浮かび上がるような合成効果を実現しています。pointer-events: noneで最背面に配置し、HTMLのテキストが上に重なるレイヤー構成です。.kumonosu-content内のテキストを書き換えれば、任意の文章に変更できます。テキスト量が変わるとスクロール範囲も変わり、色変化の速さに影響します。light0.color.setHSL(y_ratio, 0.5, 0.5)の彩度(0.5)と明度(0.5)を変更すると、色の鮮やかさや明るさを調整できます。light0_min_intensityとlight0_max_intensityを変更すると、光の強さの範囲を調整できます。light0_min_heightとlight0_max_heightで光源の矩形サイズの範囲を調整でき、光のやわらかさが変わります。roughnessを大きくするとマットな質感、小さくするとツヤのある質感になります。metalnessを上げると金属的な反射が加わります。bodyのbackgroundを変更すると、透過合成後の見た目が変わります。白以外の背景でも動作しますが、透過の仕組みが輝度ベースのため暗い背景では効果が異なります。.kumonosu-contentでフォントサイズ・行間・色を自由に変更できます。esm.sh経由でThree.js v0.184.0のWebGPUビルドを読み込んでいます。本番環境ではバージョン固定やローカルホスティングを推奨します。RectAreaLightTexturesLib.init()を呼び出しています。この処理が失敗するとライティングが正しく表示されません。pointermoveイベントを使用しているためタッチ操作にも反応しますが、タッチ中のみイベントが発火するため、PCのマウスホバーほどのなめらかな追従にはなりません。await new THREE.WebGPURenderer().init()をトップレベルで使用しているため、type="module"が必須です。position: fixedで固定されており、スクロールはHTMLテキスト側で発生します。キャンバス自体はスクロールしません。