JSで作るマウスに合わせて動く柔らかなスポットライト

ホバー

JSで作るマウスに合わせて動く柔らかなスポットライト

投稿日2026/01/28

更新日2026/1/27

Web表現において、マウスに光が追従する演出は珍しいものではありません。
しかし多くの場合、それはスポットライトのように見えたり、円形の境界が目立ってしまい、どうしても作り物感が残ります。

本実装では「光を当てる」のではなく、「そこが発光している」という考え方を採用しました。
Three.js とフラグメントシェーダを用い、光の減衰、反射、グローをすべて GPU 上で計算することで、CSS では到達できない質感と没入感を目指しています。

背景には動画テクスチャを使用し、静止画では得られない情報量と動きを加えています。

Preview プレビュー

Code コード

body,
html {
	margin: 0;
	padding: 0;
	overflow: hidden;
	background: #000;
}
#ev-canvas {
	display: block;
	width: 100vw;
	height: 100vh;
	cursor: none;
}
/**
 * 【動画版:最高画質・コントラスト重視 パラメータ】 
 */
const CONFIG = {
    // 動画のパス(mp4推奨)
    videoUrl: "/wp-content/uploads/2026/01/127_movie.mp4", 
    
    // --- サイズ調整 ---
    displayScale: 1.2,        // 動画は解像度が画像より低いため 1.0 前後がおすすめ
    
    // --- 3Dティルト ---
    tiltAmount: 0.12,         
    
    // --- 光源の設定 ---
    ambientBrightness: 0.01,  
    lightStrength: 3.5,       
    lightRadius: 0.3,        
    coreSize: 0.008,          
    
    // --- 反射・質感(動画のノイズを抑えるため少し調整) ---
    specularStrength: 1.8,    
    shininess: 15.0,          
    lightHeight: 0.15,        
    
    // --- グロー ---
    midColorHex: 0xff8c3c,    
    wideColorHex: 0x2244ff,   
    
    lerpFactor: 0.1          
};

class EmissiveVideoApp {
    constructor() {
        this.midColor = new THREE.Color(CONFIG.midColorHex);
        this.wideColor = new THREE.Color(CONFIG.wideColorHex);

        this.renderer = new THREE.WebGLRenderer({ 
            antialias: true, 
            alpha: true, 
            powerPreference: "high-performance",
            precision: "highp"
        });
        
        this.renderer.domElement.id = 'ev-canvas';
        this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
        this.renderer.setSize(window.innerWidth, window.innerHeight);
        document.body.appendChild(this.renderer.domElement);

        this.scene = new THREE.Scene();
        this.camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.1, 100);
        this.camera.position.z = 3;

        this.mouse = new THREE.Vector2(0.5, 0.5);
        this.targetMouse = new THREE.Vector2(0.5, 0.5);
        this.init();
    }

    async init() {
        // --- ビデオ要素の作成 ---
        const video = document.createElement('video');
        video.src = CONFIG.videoUrl;
        video.loop = true;
        video.muted = true; // 自動再生にはミュートが必須
        video.playsInline = true;
        video.crossOrigin = "anonymous";
        video.play();

        // 動画の読み込み完了を待つ
        video.onloadeddata = () => {
            const tex = new THREE.VideoTexture(video);
            
            // 動画のボヤけを防ぐ設定
            const maxAnisotropy = this.renderer.capabilities.getMaxAnisotropy();
            tex.anisotropy = maxAnisotropy;
            tex.minFilter = THREE.LinearFilter;
            tex.magFilter = THREE.LinearFilter;
            
            this.texture = tex;
            this.videoElement = video; // 比率取得用
            this.initMesh();
            this.initEvents();
            this.animate();
        };
    }

    getFitSize() {
        const fovRad = (this.camera.fov * Math.PI) / 180;
        const viewHeight = 2 * Math.tan(fovRad / 2) * this.camera.position.z;
        const viewWidth = viewHeight * this.camera.aspect;
        
        // 動画の縦横比を使用
        const videoAspect = this.videoElement.videoWidth / this.videoElement.videoHeight;
        const screenAspect = window.innerWidth / window.innerHeight;

        let width, height;
        if (videoAspect > screenAspect) {
            width = viewWidth; height = viewWidth / videoAspect;
        } else {
            height = viewHeight; width = viewHeight * videoAspect;
        }
        return { width: width * CONFIG.displayScale, height: height * CONFIG.displayScale };
    }

    initMesh() {
        const size = this.getFitSize();

        const vertexShader = `
            varying vec2 vUv;
            void main() {
                vUv = uv;
                gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
            }
        `;

        const fragmentShader = `
            precision highp float;
            uniform sampler2D uTex;
            uniform vec2 uMouse;
            uniform vec2 uResolution;
            uniform float uAmbient;
            uniform float uLightStrength;
            uniform float uLightRadius;
            uniform float uSpecularStrength;
            uniform float uShininess;
            uniform float uLightHeight;
            uniform float uCoreSize;
            uniform vec3  uMidColor;
            uniform vec3  uWideColor;
            varying vec2 vUv;

            float getLum(vec2 uv) {
                return dot(texture2D(uTex, uv).rgb, vec3(0.299, 0.587, 0.114));
            }

            vec3 getReflectNormal(vec2 uv) {
                float eps = 0.003; 
                float h = getLum(uv);
                float hx = getLum(uv + vec2(eps, 0.0));
                float hy = getLum(uv + vec2(0.0, eps));
                // 動画はノイズが出やすいため、凹凸強調を0.4程度に抑える
                return normalize(vec3((h - hx) * 0.4, (h - hy) * 0.4, 0.1));
            }

            void main() {
                vec2 aspect = vec2(uResolution.x / uResolution.y, 1.0);
                vec2 mouse = uMouse * aspect;
                vec2 uv = vUv * aspect;
                float dist = distance(mouse, uv);

                vec3 baseColor = texture2D(uTex, vUv).rgb;

                float lightFalloff = 1.0 / (1.0 + pow(dist / uLightRadius, 4.5));
                vec3 litImage = baseColor * lightFalloff * uLightStrength;

                vec3 normal = getReflectNormal(vUv);
                vec3 lightDir = normalize(vec3(mouse - uv, uLightHeight));
                vec3 viewDir = vec3(0.0, 0.0, 1.0);
                vec3 halfDir = normalize(lightDir + viewDir);
                float spec = pow(max(dot(normal, halfDir), 0.0), uShininess);
                
                vec3 reflection = vec3(spec) * uSpecularStrength * lightFalloff * (baseColor + 0.2);

                float core = exp(-(dist * dist) / (2.0 * uCoreSize * uCoreSize)) * 2.5;
                float midGlow = 1.0 / (1.0 + pow(dist / 0.18, 3.5));
                vec3 glowEffect = (uMidColor * midGlow * 0.5);

                vec3 finalColor = (baseColor * uAmbient) + litImage + reflection + vec3(core) + glowEffect;
                
                float edge = smoothstep(0.0, 0.005, vUv.x) * smoothstep(1.0, 0.995, vUv.x) *
                             smoothstep(0.0, 0.005, vUv.y) * smoothstep(1.0, 0.995, vUv.y);

                gl_FragColor = vec4(finalColor * edge, 1.0);
            }
        `;

        this.material = new THREE.ShaderMaterial({
            transparent: true,
            uniforms: {
                uTex: { value: this.texture },
                uMouse: { value: this.mouse },
                uResolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) },
                uAmbient: { value: CONFIG.ambientBrightness },
                uLightStrength: { value: CONFIG.lightStrength },
                uLightRadius: { value: CONFIG.lightRadius },
                uSpecularStrength: { value: CONFIG.specularStrength },
                uShininess: { value: CONFIG.shininess },
                uLightHeight: { value: CONFIG.lightHeight },
                uCoreSize: { value: CONFIG.coreSize },
                uMidColor: { value: this.midColor },
                uWideColor: { value: this.wideColor }
            },
            vertexShader,
            fragmentShader
        });

        this.mesh = new THREE.Mesh(new THREE.PlaneGeometry(size.width, size.height), this.material);
        this.scene.add(this.mesh);
    }

    initEvents() {
        window.addEventListener('mousemove', (e) => {
            this.targetMouse.set(e.clientX / window.innerWidth, 1.0 - (e.clientY / window.innerHeight));
        });
        window.addEventListener('resize', () => {
            this.camera.aspect = window.innerWidth / window.innerHeight;
            this.camera.updateProjectionMatrix();
            this.renderer.setSize(window.innerWidth, window.innerHeight);
            const size = this.getFitSize();
            this.mesh.geometry.dispose();
            this.mesh.geometry = new THREE.PlaneGeometry(size.width, size.height);
            this.material.uniforms.uResolution.value.set(window.innerWidth, window.innerHeight);
        });
    }

    animate() {
        requestAnimationFrame(() => this.animate());
        this.mouse.lerp(this.targetMouse, CONFIG.lerpFactor);
        this.mesh.rotation.x = (this.mouse.y - 0.5) * CONFIG.tiltAmount;
        this.mesh.rotation.y = -(this.mouse.x - 0.5) * CONFIG.tiltAmount;
        this.renderer.render(this.scene, this.camera);
    }
}

window.addEventListener('load', () => {
    if (typeof THREE !== 'undefined') new EmissiveVideoApp();
});
https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js

Explanation 詳しい説明

仕様

本デモは Three.js の ShaderMaterial を使用し、フルスクリーンのプレーンに対してフラグメントシェーダで描画しています。
背景は VideoTexture として読み込み、マウス位置を疑似的な点光源として扱います。

シェーダ内では、単に画面を明るくするのではなく、光が動画表面に当たって反射しているように見えることを重視して計算を行っています。
そのため、動画は単なる背景ではなく、光に反応する「存在感のある面」として表現されます。

具体的には、以下の要素を同時に計算しています。

  • 距離に応じた明るさの減衰
  • 指数関数による中心核の発光
  • 動画の輝度差から擬似的に法線を推定し、光の反射として利用
  • 中域および広域に広がる色付きグロー

これらを組み合わせることで、マウス位置の光が動画表面をなめるように反射し、背景が「そこに置かれた映像素材」として感じられるようになります。

CSS の radial-gradientmask を使った疑似的な光表現は一切使用していません。

ライティング設計

スポットライトやディレクショナルライトのような「照射」モデルではなく、距離に応じて自然に減衰する発光体モデルを採用しています。

中心付近は Gaussian による強い発光を持ち、中域以降はロングテール型の減衰で滑らかに広がります。
減衰を途中で 0 に切らないことで、円形の輪郭やリング状のアーティファクトが出ないよう設計しています。

画面周辺は暗く保ち、ページ全体が持ち上がらないようにしています。

動画テクスチャと反射

動画テクスチャの輝度差を利用して簡易的な法線を生成し、スペキュラ反射を計算しています。
動画由来のノイズが強調されすぎないよう、凹凸の強さは控えめに調整しています。

マウス位置に応じて反射方向が変化するため、平面でありながら立体感のある見え方になります。

カスタマイズ可能な設定項目(CONFIG)

  • lightStrength:光全体の強さ
    値を大きくすると発光が強くなり、全体的に明るい印象になります。
  • lightRadius:光の広がり
    光がどの範囲まで影響するかを制御します。大きくすると柔らかく広がり、小さくすると引き締まった表現になります。
  • coreSize:中心核のサイズ
    マウス位置に現れる最も強い発光部分の大きさを指定します。小さいほどシャープで鋭い光になります。
  • specularStrength:反射の強さ
    動画テクスチャ上に現れるハイライトの強度を調整します。金属感を強めたい場合に有効です。
  • shininess:反射の鋭さ
    ハイライトの集中度を制御します。値を上げると鋭く、下げると柔らかい反射になります。
  • ambientBrightness:暗部の持ち上げ量
    光が当たっていない部分の最低輝度を調整します。暗闇感を保ちつつ黒つぶれを防げます。
  • midColorHex:中域グローの色
    光の中距離に広がるグローの色を指定します。暖色系を使うと反射感が強調されます。
  • wideColorHex:広域グローの色
    空気感として広がる外側のグローの色を指定します。寒色系にすると奥行きが出ます。
  • tiltAmount:マウス追従による3Dティルト量
    マウス移動に応じたプレーンの傾き具合を制御します。控えめにすると落ち着いた印象になります。
  • lerpFactor:マウス追従のなめらかさ
    マウス位置への追従速度を調整します。小さいほどゆったりと追従します。

用途や動画素材に合わせて細かくチューニング可能です。

背景動画の差し替え

  • videoUrl:背景動画のパス
    この値を変更するだけで、背景を自由に差し替えられます。
    実写・CG・抽象映像・ノイズ動画など、素材を変えることで雰囲気を大きく変えられます。

光の計算はすべてシェーダ側で行っているため、背景動画を変更してもライティングロジックは共通です。
用途や演出に合わせて、動画素材とパラメータを組み合わせて調整できます。

注意点

WebGL を使用するため、非常に古いブラウザや端末では動作しません。
動画の自動再生にはミュート設定が必要です。
高品質設定では GPU 負荷が高くなるため、モバイル向けにはパラメータの調整を推奨します。
見た目の品質は、使用する動画素材のコントラストや情報量に強く依存します。