commit 73f92c3bd0f7f606055031954ee0a3b9c02553d9
parent 2e10db4c6ef9b0d32eaf356da22118a3285e0b4d
Author: minerva-jupiter <ryouturn@gmail.com>
Date: Sun, 26 Oct 2025 23:04:51 +0900
feat(oil-art): Implement interactive liquid art canvas with audio progression
Diffstat:
4 files changed, 438 insertions(+), 1 deletion(-)
diff --git a/app/components/4_oil.tsx b/app/components/4_oil.tsx
@@ -0,0 +1,120 @@
+import { Canvas } from '@react-three/fiber';
+import { OrbitControls } from '@react-three/drei';
+import { OilArtPlane, OilArtAPI } from './OilArt/OilArtPlane.tsx';
+import { useEffect, useRef, useState } from 'react';
+import * as THREE from 'three';
+import { StageProps } from '../ctrl/page.tsx';
+
+// --- オーディオ関連の定数とカスタムフック ---
+const AUDIO_SOURCES: Record<number, string> = {
+ 1: '/audio/001.wav',
+ 2: '/audio/002.wav',
+};
+
+// シーケンシャルな音声再生と状態遷移を制御するカスタムフック (修正版)
+const useSequentialAudio = (initialState: number, audioSources: Record<number, string>, onComplete: () => void) => {
+ const [sceneState, setSceneState] = useState(initialState);
+ const audioRef = useRef<HTMLAudioElement | null>(null);
+
+ // 現在の状態に対応する音源のURLを取得
+ const currentAudioUrl = audioSources[sceneState];
+
+ // 音声再生のロジック
+ useEffect(() => {
+ // --- 1. 終了条件のチェック ---
+ if (sceneState > Math.max(...Object.keys(audioSources).map(Number))) {
+ // 定義された全ての音源が再生された後、onCompleteを実行
+ console.log("All audio finished. Calling onComplete.");
+ onComplete();
+ return;
+ }
+
+ // 現在の状態に対応する音源がない場合、ここで処理を停止
+ if (!currentAudioUrl) {
+ return;
+ }
+
+ console.log(`Starting setup for state ${sceneState}: ${currentAudioUrl}`);
+
+ // --- 2. 古いAudioオブジェクトのクリーンアップ ---
+ const existingAudio = audioRef.current;
+ if (existingAudio) {
+ existingAudio.pause();
+ // イベントリスナーの削除 (次のステップで新しいAudioオブジェクトにリスナーを設定するため)
+ // このクリーンアップが重要です。古いAudioオブジェクトが'ended'イベントをトリガーするのを防ぎます。
+ // `oncanplaythrough`リスナーはここでは不要ですが、もしあれば削除すべきです。
+ // Audioオブジェクトのライフサイクルを明確にするため、毎回新しいインスタンスを生成します。
+ }
+
+ // --- 3. 新しいAudioオブジェクトの作成と設定 ---
+ const audio = new Audio(currentAudioUrl);
+ audioRef.current = audio;
+
+ // 再生が終了したときのハンドラを定義
+ const handleAudioEnded = () => {
+ console.log(`Audio ${sceneState} finished. Transitioning state.`);
+ // 次の状態へ遷移 (例: 1 -> 2, 2 -> 3)
+ setSceneState(prev => prev + 1);
+ };
+
+ audio.addEventListener('ended', handleAudioEnded);
+
+ // --- 4. 再生の開始 ---
+ // ロードやイベントを待たずに、すぐに再生を試みる
+ audio.play().catch(e => {
+ console.warn(`Audio playback failed for state ${sceneState} (requires user interaction):`, e);
+ // ユーザーに最初のクリックを促すメッセージなどを表示すると良い
+ });
+
+
+ // --- 5. クリーンアップ関数 ---
+ return () => {
+ // アンマウント/状態遷移時に現在のAudioオブジェクトを確実に停止し、リスナーを削除
+ if (audio === audioRef.current) { // 現在設定したAudioオブジェクトであることを確認
+ audio.pause();
+ audio.removeEventListener('ended', handleAudioEnded);
+ }
+ };
+
+ }, [sceneState, audioSources, onComplete]); // sceneStateとcurrentAudioUrlは基本的に連動するため、sceneStateを依存配列に含める
+
+ return sceneState;
+};
+
+export default function OilArtCanvasWrapper({onComplete}:StageProps) {
+ const apiRef = useRef<OilArtAPI>({} as OilArtAPI);
+
+ const sceneState = useSequentialAudio(1, AUDIO_SOURCES, onComplete);
+
+ useEffect(() => {
+ // 外部API経由でランダムな滴下を定期的に実行
+ const intervalId = setInterval(() => {
+ if (apiRef.current.dropOil) {
+ const x = Math.random() * 2 - 1; // -1.0〜1.0
+ const y = Math.random() * 2 - 1; // -1.0〜1.0
+ const color = new THREE.Color(Math.random(), Math.random(), Math.random());
+ apiRef.current.dropOil({ x, y }, color);
+
+ // 傾きもランダムに変更
+ if (sceneState == 2){
+ const tiltX = Math.random() * 0.8 - 0.4;
+ const tiltY = Math.random() * 0.8 - 0.4;
+ apiRef.current.tilt({ x: tiltX, y: tiltY });
+ }else{
+ apiRef.current.tilt({ x: 0, y: 0});
+ }
+ }
+ }, 1500);
+
+ return () => clearInterval(intervalId);
+ }, []);
+
+ return (
+ <Canvas camera={{ position: [0, 0, 1] }}>
+ <color attach="background" args={[0x000000]} />
+ {/* OilArtPlaneに外部制御用のAPIを渡す */}
+ <OilArtPlane onTriggerAPI={(api) => (apiRef.current = api)} />
+ <OrbitControls enableZoom={false} enablePan={false} enableRotate={false} />
+ </Canvas>
+ );
+}
diff --git a/app/components/OilArt/OilArtMaterial.d.ts b/app/components/OilArt/OilArtMaterial.d.ts
@@ -0,0 +1,18 @@
+// src/types/OilArtMaterial.d.ts
+import { Material } from 'three';
+
+declare global {
+ namespace JSX {
+ interface IntrinsicElements {
+ oilArtMaterial: ThreeElements['shaderMaterial'] & {
+ u_time?: number;
+ u_resolution?: THREE.Vector2;
+ u_dropPosition?: THREE.Vector2;
+ u_dropColor?: THREE.Color;
+ u_dropRadius?: number;
+ u_tiltDirection?: THREE.Vector2;
+ u_feedbackTexture?: THREE.Texture | null; // Ping-Pongバッファ用
+ };
+ }
+ }
+}
diff --git a/app/components/OilArt/OilArtPlane.tsx b/app/components/OilArt/OilArtPlane.tsx
@@ -0,0 +1,293 @@
+const vertexShader = `
+void main() {
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+}
+`;
+
+const fragmentShader = `
+uniform float u_time;
+uniform vec2 u_resolution;
+uniform vec3 u_dropColor;
+uniform vec2 u_dropPosition;
+uniform float u_dropRadius;
+uniform vec2 u_tiltDirection; // -1.0から1.0の傾きベクトル
+uniform sampler2D u_feedbackTexture;
+
+// Simplex Noise (Based on Stefan Gustavson's implementation)
+// -----------------------------------------------------------
+
+vec3 mod289(vec3 x) {
+ return x - floor(x * (1.0 / 289.0)) * 289.0;
+}
+
+vec2 mod289(vec2 x) {
+ return x - floor(x * (1.0 / 289.0)) * 289.0;
+}
+
+vec3 permute(vec3 x) {
+ return mod289(((x*34.0)+1.0)*x);
+}
+
+// 2D Simplex Noise
+float noise(vec2 v)
+{
+ const vec4 C = vec4(0.211324865405187, // (3.0-sqrt(3.0))/6.0
+ 0.366025403784439, // 0.5*(sqrt(3.0)-1.0)
+ -0.577350269189626, // -1.0 + 2.0 * (3.0-sqrt(3.0))/6.0
+ 0.024390243902439); // 1.0/41.0
+ // First corner
+ vec2 i = floor(v + dot(v, C.yy) );
+ vec2 x0 = v - i + dot(i, C.xx) ;
+
+ // Other corners
+ vec2 i1;
+ //i1.x = step( x0.y, x0.x ); // x.y > x.x ? 1.0 : 0.0
+ //i1.y = 1.0 - i1.x;
+ i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
+ // x0 is the point after skewing and de-skewing to form the right isosceles triangle.
+
+ vec4 x12 = x0.xyxy + C.xxzz;
+ x12.xy -= i1;
+
+ // Permutations
+ i = mod289(i); // Avoids truncation effects in GLSL
+ vec3 p = permute( permute( i.y + vec3(0.0, i1.y, 1.0 ))
+ + i.x + vec3(0.0, i1.x, 1.0 ) );
+
+ vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
+ m = m*m ;
+ m = m*m ;
+
+ // Gradients: 41 points on a unit square
+ vec3 x = 2.0 * fract(p * C.www) - 1.0;
+ vec3 y = abs(x) - 0.4;
+ vec3 h = floor(x + 0.5);
+ vec3 ox = h * C.z;
+ vec3 a0 = x - h;
+
+ // Final gradient computation for the three simplex corners
+ m *= 1.79284291400159 - 0.85373472095314 * ( a0*a0 + y*y );
+
+ // Gradients can be written concisely as dot( vec*x, vec*y )
+ vec3 g;
+ g.x = a0.x * x0.x + y.x * x0.y;
+ g.yz = a0.yz * x12.xz + y.yz * x12.yw;
+ return 130.0 * dot(m, g);
+}
+// -----------------------------------------------------------
+
+void main() {
+ vec2 uv = gl_FragCoord.xy / u_resolution.xy;
+
+ // --- ノイズベクトル生成 ---
+ vec2 noise_direction = vec2(
+ noise(uv * 10.0 + u_time * 0.1),
+ noise(uv * 20.0 - u_time * 0.2)
+ ) - 0.5;
+
+ vec2 total_displacement = (u_tiltDirection * 0.005) + (noise_direction * 0.001);
+
+ vec2 shifted_uv = uv - total_displacement;
+ shifted_uv = clamp(shifted_uv, 0.001, 0.999);
+
+ vec4 prev_oil = texture2D(u_feedbackTexture, shifted_uv);
+ vec3 current_oil_color = prev_oil.rgb;
+ float current_alpha = prev_oil.a;
+
+ // 💡 減衰(フェードアウト)係数を調整 (よりゆっくり淡くなるように1.0に近づける)
+ // ただし、完全に1.0だと色が残るため、非常に小さい値で調整
+ float decay_factor = 0.998; // 例: 非常にゆっくりと色が減衰
+ current_oil_color *= decay_factor;
+ current_alpha *= decay_factor;
+
+ // --- 2. 滴下処理(大幅変更) ---
+ if (u_dropPosition.x > -0.9) {
+ vec2 drop_uv = (u_dropPosition + 1.0) / 2.0;
+ vec2 relative_pos = uv - drop_uv; // 滴下点からの相対位置
+
+ // 1. 滴下点からの標準距離を計算
+ float standard_dist = length(relative_pos);
+
+ // 2. 角度(theta)を計算
+ // atan2(y, x) は [-PI, PI] の角度を返す
+ float theta = atan(relative_pos.y, relative_pos.x);
+
+ // 3. 角度と時間に基づいてノイズを生成
+ // ノイズは [-1, 1] の範囲で、輪郭に複雑な歪みを与える
+ float noise_freq = 5.0; // ノイズの波の数(輪郭のギザギザの細かさ)
+ float noise_val = noise(vec2(theta * noise_freq, u_time * 0.5)) * 2.0 - 1.0;
+
+ // 4. ノイズによる距離補正 (歪曲)
+ // 歪み係数: 0.1 は歪みの強さ (この値を大きくすると r'(θ) が急になります)
+ float distortion_strength = 0.001;
+
+ // r(θ) のイメージ: 標準の半径 u_dropRadius にノイズを加える
+ // この計算により、ノイズ値が r(θ) の変化率 r'(θ) に大きく影響を与える
+ float distorted_radius = u_dropRadius + (noise_val * distortion_strength);
+
+ // 5. ブレンド計算
+ // 標準距離 standard_dist と歪んだ半径 distorted_radius を比較
+ if (standard_dist < distorted_radius) {
+ // スムースステップで中心から輪郭までのグラデーションを制御
+ float blend_factor = 1.0 - smoothstep(distorted_radius * 0.9, distorted_radius, standard_dist);
+
+ // 輪郭を暗くする処理(維持)
+ float edge_darkness = 1.0 - smoothstep(distorted_radius * 0.4, distorted_radius, standard_dist);
+ vec3 darker_drop_color = u_dropColor * (0.25 + edge_darkness * 0.75);
+
+ // 混色ではなく、色の上書きブレンド
+ current_oil_color = mix(current_oil_color, darker_drop_color, blend_factor);
+ current_alpha = mix(current_alpha, 1.0, blend_factor);
+ }
+ }
+
+ // 💡 鮮やかさ(ビビッドさ)の強調処理
+ // 1. RGBの平均輝度(グレースケール値)を計算
+ float average_light = (current_oil_color.r + current_oil_color.g + current_oil_color.b) / 3.0;
+
+ // 2. 彩度を強調する係数 (Saturation Boost Factor)
+ // 1.0より大きい値にすると彩度が増します。2.0〜3.0が推奨値です。
+ float saturation_boost = 1.035;
+
+ // 3. 平均輝度と現在の色を mix し、彩度を上げる
+ // (current_oil_color - average_light) は色の「差」成分。
+ // この差を大きくすることで彩度が増します。
+ vec3 vivid_color = mix(vec3(average_light), current_oil_color, saturation_boost);
+
+ // 結果をクランプして色を [0, 1] の範囲に収める
+ current_oil_color = clamp(vivid_color, 0.0, 1.0);
+
+ gl_FragColor = vec4(current_oil_color, current_alpha);
+}
+`;
+
+import { useFrame, extend, useThree } from '@react-three/fiber';
+import { useRef, useState, useEffect, useCallback } from 'react';
+import { shaderMaterial } from '@react-three/drei';
+import * as THREE from 'three';
+
+// -----------------------------------------------------------
+// GLSL シェーダーコード
+// -----------------------------------------------------------
+// (上記2.の vertexShader と fragmentShader をここに定義またはインポート)
+
+// -----------------------------------------------------------
+// カスタムマテリアルの定義と拡張 (型定義は src/types/OilArtMaterial.d.ts に依存)
+// -----------------------------------------------------------
+const OilArtMaterial = shaderMaterial(
+ {
+ u_time: 0,
+ u_resolution: new THREE.Vector2(),
+ u_dropColor: new THREE.Color(0xffffff),
+ u_dropPosition: new THREE.Vector2(-1, -1), // 無効な初期値
+ u_dropRadius: 0.05,
+ u_tiltDirection: new THREE.Vector2(0, 0),
+ u_feedbackTexture: null as THREE.Texture | null,
+ },
+ vertexShader,
+ fragmentShader
+);
+extend({ OilArtMaterial });
+
+// -----------------------------------------------------------
+// 外部公開APIとPropsの型定義
+// -----------------------------------------------------------
+export interface OilArtAPI {
+ dropOil: (position: { x: number; y: number }, color: THREE.Color) => void;
+ tilt: (direction: { x: number; y: number }) => void;
+}
+
+interface OilArtPlaneProps {
+ onTriggerAPI: (api: OilArtAPI) => void;
+}
+
+type OilArtMaterialRef = THREE.ShaderMaterial & typeof OilArtMaterial.prototype;
+
+// -----------------------------------------------------------
+// OilArtPlane 本体
+// -----------------------------------------------------------
+export const OilArtPlane: React.FC<OilArtPlaneProps> = ({ onTriggerAPI }) => {
+ const materialRef = useRef<OilArtMaterialRef>(null!);
+ const meshRef = useRef<THREE.Mesh>(null!);
+ const { gl, size, viewport, camera } = useThree();
+
+ // Ping-pongバッファのためのレンダーターゲット (テクスチャを交互に書き込む)
+ const [rtA] = useState(() => new THREE.WebGLRenderTarget(size.width, size.height));
+ const [rtB] = useState(() => new THREE.WebGLRenderTarget(size.width, size.height));
+ const currentRenderTexture = useRef(rtA);
+ const feedbackRenderTexture = useRef(rtB);
+
+ // 初期化とリサイズ処理
+ useEffect(() => {
+ if (materialRef.current) {
+ materialRef.current.uniforms.u_resolution.value.set(size.width, size.height);
+ rtA.setSize(size.width, size.height);
+ rtB.setSize(size.width, size.height);
+
+ // 初回は基本色でテクスチャをクリア (任意)
+ gl.setRenderTarget(rtA);
+
+ // 💡 修正点: setClearColor(Color, alpha) を使用
+ // THREE.Colorオブジェクトを使って色を設定
+ const baseColor = new THREE.Color(0.0, 0.0, 0.002);
+ const alpha = 0.7;
+ gl.setClearColor(baseColor, alpha);
+
+ // gl.clear() で設定した色とアルファ値を使ってクリア
+ gl.clear();
+ gl.setRenderTarget(null); // デフォルトに戻す
+ }
+ }, [gl, size, rtA, rtB]);
+
+ // 滴下処理: positionはビューポート座標 (-1.0〜1.0)
+ const dropOil = useCallback((position: { x: number; y: number }, color: THREE.Color) => {
+ if (materialRef.current) {
+ materialRef.current.uniforms.u_dropPosition.value.set(position.x, position.y);
+ materialRef.current.uniforms.u_dropColor.value.copy(color);
+ // ドロップをトリガー
+ }
+ }, []);
+
+ // 傾き処理: directionはベクトル (-1.0〜1.0)
+ const tilt = useCallback((direction: { x: number; y: number }) => {
+ if (materialRef.current) {
+ materialRef.current.uniforms.u_tiltDirection.value.set(direction.x, direction.y);
+ }
+ }, []);
+
+ // 外部APIの公開
+ useEffect(() => {
+ onTriggerAPI({ dropOil, tilt });
+ }, [onTriggerAPI, dropOil, tilt]);
+
+ useFrame(({ clock }) => {
+ if (!materialRef.current || !meshRef.current) return;
+
+ materialRef.current.uniforms.u_time.value = clock.getElapsedTime();
+
+ // 1. **オフスクリーンレンダリング**: 次のフレームの状態を計算し、`feedbackRenderTexture`に書き込む
+ gl.setRenderTarget(feedbackRenderTexture.current);
+ // 書き込む際には、前のフレームの状態を`u_feedbackTexture`としてシェーダーに渡す
+ materialRef.current.uniforms.u_feedbackTexture.value = currentRenderTexture.current.texture;
+ gl.render(meshRef.current, camera); // Ping-Pongバッファの計算
+
+ // 2. **画面への最終描画**: 計算したばかりのテクスチャを画面に表示
+ gl.setRenderTarget(null); // デフォルトのレンダーターゲット(画面)に戻す
+ materialRef.current.uniforms.u_feedbackTexture.value = feedbackRenderTexture.current.texture;
+ gl.render(meshRef.current, camera);
+
+ // 3. **バッファのスワップ**: 次のフレームのために currentとfeedbackを入れ替える
+ [currentRenderTexture.current, feedbackRenderTexture.current] = [feedbackRenderTexture.current, currentRenderTexture.current];
+
+ // ドロップをリセット: 1フレーム描画したら無効な位置に戻す
+ // これにより、毎フレーム新しい色が滴下されるのではなく、トリガー時のみ追加される
+ materialRef.current.uniforms.u_dropPosition.value.set(-1, -1);
+ });
+
+ return (
+ <mesh ref={meshRef} scale={[viewport.width, viewport.height, 1]}>
+ <planeGeometry args={[1, 1]} />
+ <oilArtMaterial ref={materialRef} />
+ </mesh>
+ );
+};
diff --git a/app/ctrl/page.tsx b/app/ctrl/page.tsx
@@ -19,6 +19,9 @@ const SecondDraw = dynamic<StageProps>(() => import('../components/2_draw.tsx'),
const ThreeVR = dynamic<StageProps>(()=>import('../components/3_vr.tsx'),{
loading: Loading,
});
+const OilArt = dynamic<StageProps>(()=>import('../components/4_oil.tsx'),{
+ loading: Loading,
+});
const Error = dynamic<StageProps>(() => import('../components/error.tsx'), {
loading: Loading,
});
@@ -28,7 +31,7 @@ export default function Chat() {
const pageRef = useRef<HTMLDivElement>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isInitialCheckDone, setIsInitialCheckDone] = useState(false);
- const [ stage, setStage ] = useState(1);
+ const [ stage, setStage ] = useState(4);
const handleStageComplete = useCallback(() => {
setStage(stage + 1);
}, []);
@@ -45,6 +48,9 @@ export default function Chat() {
case 3:
StageComponent = <ThreeVR onComplete={handleStageComplete}/>;
break;
+ case 4:
+ StageComponent = <OilArt onComplete={handleStageComplete}/>
+ break;
default:
StageComponent = <Error onComplete={handleStageComplete}/>;
}