@@ -7,6 +7,7 @@ const DEVICE_THREAD_HINT = navigator.hardwareConcurrency || 4;
77const DEVICE_MEMORY_HINT = navigator . deviceMemory || 4 ;
88const PERF_TIER = Math . min ( 2 , DEVICE_THREAD_HINT / 4 ) * Math . min ( 1.5 , DEVICE_MEMORY_HINT / 4 ) ;
99const PARTICLE_COUNT = PERF_TIER >= 1.4 ? 20000 : ( PERF_TIER >= 0.9 ? 16000 : 12000 ) ;
10+ const BATTLE_PARTICLE_SCALE = 0.65 ;
1011const SWARM_FORCE = 0.5 ;
1112const SHAPE_FORCE = 0.2 ;
1213const DAMPING = 0.96 ;
@@ -32,12 +33,15 @@ const COLOR_FLASH = { r: 1.0, g: 1.0, b: 1.0 };
3233const COLOR_BATTLE = { r : 1.0 , g : 0.9 , b : 0.2 } ;
3334const RENDER_PIXEL_RATIO = Math . min ( window . devicePixelRatio || 1 , PERF_TIER >= 1.4 ? 1.15 : 1.0 ) ;
3435const HAND_PERSIST_MS = 400 ; // keep hand "active" for 400ms after loss
36+ const MAX_GPU_EXPLOSIONS = 4 ;
3537
3638// --- State ---
3739let currentShape = 'swarm' ;
3840let width = window . innerWidth ;
3941let height = window . innerHeight ;
4042let isSwarmMode = true ;
43+ let activeParticleCount = PARTICLE_COUNT ;
44+ let targetActiveParticleCount = PARTICLE_COUNT ;
4145let isPinching = false ;
4246let pinchDistance = 0 ;
4347let handCenter = new THREE . Vector3 ( ) ;
@@ -179,11 +183,97 @@ for (let i = 0; i < PARTICLE_COUNT; i++) {
179183
180184geometry . setAttribute ( 'position' , new THREE . BufferAttribute ( pPos , 3 ) ) ;
181185geometry . setAttribute ( 'color' , new THREE . BufferAttribute ( pCol , 3 ) ) ;
186+ geometry . attributes . position . setUsage ( THREE . DynamicDrawUsage ) ;
187+ geometry . attributes . color . setUsage ( THREE . DynamicDrawUsage ) ;
188+ geometry . setDrawRange ( 0 , activeParticleCount ) ;
189+
190+ const particleUniforms = {
191+ uMap : { value : glowTexture } ,
192+ uPointSize : { value : 4.0 } ,
193+ uOpacity : { value : 0.85 } ,
194+ uPixelRatio : { value : renderer . getPixelRatio ( ) } ,
195+ uHandLeft : { value : new THREE . Vector3 ( ) } ,
196+ uHandRight : { value : new THREE . Vector3 ( ) } ,
197+ uBeamP1Origin : { value : new THREE . Vector3 ( ) } ,
198+ uBeamP1Head : { value : new THREE . Vector3 ( ) } ,
199+ uBeamP2Origin : { value : new THREE . Vector3 ( ) } ,
200+ uBeamP2Head : { value : new THREE . Vector3 ( ) } ,
201+ uExplosionCount : { value : 0 } ,
202+ uExplosions : { value : Array . from ( { length : MAX_GPU_EXPLOSIONS } , ( ) => new THREE . Vector4 ( ) ) } ,
203+ uExplosionColors : { value : Array . from ( { length : MAX_GPU_EXPLOSIONS } , ( ) => new THREE . Vector3 ( ) ) }
204+ } ;
205+
206+ const particleVertexShader = `
207+ uniform float uPointSize;
208+ uniform float uPixelRatio;
209+ uniform vec3 uHandLeft;
210+ uniform vec3 uHandRight;
211+ uniform vec3 uBeamP1Head;
212+ uniform vec3 uBeamP2Head;
213+ uniform int uExplosionCount;
214+ uniform vec4 uExplosions[${ MAX_GPU_EXPLOSIONS } ];
215+ attribute vec3 color;
216+ varying vec3 vColor;
217+ varying float vEnergy;
218+
219+ void main() {
220+ vec3 worldPos = position;
221+ float energy = 0.0;
222+
223+ float leftDistSq = dot(worldPos - uHandLeft, worldPos - uHandLeft);
224+ float rightDistSq = dot(worldPos - uHandRight, worldPos - uHandRight);
225+ float beam1DistSq = dot(worldPos - uBeamP1Head, worldPos - uBeamP1Head);
226+ float beam2DistSq = dot(worldPos - uBeamP2Head, worldPos - uBeamP2Head);
227+
228+ energy += 1.0 / (1.0 + leftDistSq * 0.00008);
229+ energy += 1.0 / (1.0 + rightDistSq * 0.00008);
230+ energy += 1.3 / (1.0 + beam1DistSq * 0.00012);
231+ energy += 1.3 / (1.0 + beam2DistSq * 0.00012);
232+
233+ for (int i = 0; i < ${ MAX_GPU_EXPLOSIONS } ; i++) {
234+ if (i >= uExplosionCount) break;
235+ vec3 delta = worldPos - uExplosions[i].xyz;
236+ float distSq = dot(delta, delta);
237+ float radiusSq = uExplosions[i].w * uExplosions[i].w;
238+ if (distSq < radiusSq) {
239+ energy += 1.5 * (1.0 - distSq / max(radiusSq, 1.0));
240+ }
241+ }
182242
183- const material = new THREE . PointsMaterial ( {
184- size : 4.0 , map : glowTexture , vertexColors : true ,
185- blending : THREE . AdditiveBlending , depthTest : false ,
186- transparent : true , opacity : 0.85
243+ vColor = color;
244+ vEnergy = clamp(energy, 0.0, 2.0);
245+
246+ vec4 mvPosition = modelViewMatrix * vec4(worldPos, 1.0);
247+ gl_Position = projectionMatrix * mvPosition;
248+ gl_PointSize = uPointSize * uPixelRatio * (320.0 / max(-mvPosition.z, 1.0)) * (1.0 + vEnergy * 0.2);
249+ }
250+ ` ;
251+
252+ const particleFragmentShader = `
253+ uniform sampler2D uMap;
254+ uniform float uOpacity;
255+ varying vec3 vColor;
256+ varying float vEnergy;
257+
258+ void main() {
259+ vec4 glow = texture2D(uMap, gl_PointCoord);
260+ vec3 outColor = vColor + vEnergy * 0.08;
261+ if (vEnergy > 0.4) {
262+ outColor = mix(outColor, vec3(1.0), clamp((vEnergy - 0.4) * 0.35, 0.0, 0.35));
263+ }
264+ gl_FragColor = vec4(outColor, glow.a * uOpacity);
265+ }
266+ ` ;
267+
268+ const material = new THREE . ShaderMaterial ( {
269+ uniforms : particleUniforms ,
270+ vertexShader : particleVertexShader ,
271+ fragmentShader : particleFragmentShader ,
272+ vertexColors : true ,
273+ blending : THREE . AdditiveBlending ,
274+ depthTest : false ,
275+ depthWrite : false ,
276+ transparent : true
187277} ) ;
188278const particles = new THREE . Points ( geometry , material ) ;
189279scene . add ( particles ) ;
@@ -220,6 +310,41 @@ const origin = new THREE.Vector3(0, 0, 0);
220310const singleForceOut = { vx : 0 , vy : 0 , vz : 0 } ;
221311function clamp ( v , mn , mx ) { return Math . max ( mn , Math . min ( mx , v ) ) ; }
222312function lerpValue ( a , b , t ) { return a + ( b - a ) * t ; }
313+ function setActiveParticleTarget ( shape ) {
314+ const battleCount = Math . max ( BATTLE_PROJECTILE_COUNT * 2 + 512 , Math . floor ( PARTICLE_COUNT * BATTLE_PARTICLE_SCALE ) ) ;
315+ targetActiveParticleCount = ( shape === 'battle' ) ? battleCount : PARTICLE_COUNT ;
316+ }
317+ function syncActiveParticleCount ( ) {
318+ if ( activeParticleCount === targetActiveParticleCount ) return activeParticleCount ;
319+ const delta = targetActiveParticleCount - activeParticleCount ;
320+ const step = Math . max ( 96 , Math . ceil ( Math . abs ( delta ) * 0.18 ) ) ;
321+ activeParticleCount += Math . sign ( delta ) * Math . min ( Math . abs ( delta ) , step ) ;
322+ geometry . setDrawRange ( 0 , activeParticleCount ) ;
323+ return activeParticleCount ;
324+ }
325+ function updateParticleUniforms ( ) {
326+ particleUniforms . uPixelRatio . value = renderer . getPixelRatio ( ) ;
327+ particleUniforms . uHandLeft . value . copy ( hands . left . pos ) ;
328+ particleUniforms . uHandRight . value . copy ( hands . right . pos ) ;
329+ particleUniforms . uBeamP1Origin . value . copy ( battle . p1 . beam . origin ) ;
330+ particleUniforms . uBeamP1Head . value . copy ( battle . p1 . beam . head ) ;
331+ particleUniforms . uBeamP2Origin . value . copy ( battle . p2 . beam . origin ) ;
332+ particleUniforms . uBeamP2Head . value . copy ( battle . p2 . beam . head ) ;
333+ const count = Math . min ( battle . explosions . length , MAX_GPU_EXPLOSIONS ) ;
334+ particleUniforms . uExplosionCount . value = count ;
335+ for ( let i = 0 ; i < MAX_GPU_EXPLOSIONS ; i ++ ) {
336+ const posUniform = particleUniforms . uExplosions . value [ i ] ;
337+ const colorUniform = particleUniforms . uExplosionColors . value [ i ] ;
338+ if ( i < count ) {
339+ const exp = battle . explosions [ i ] ;
340+ posUniform . set ( exp . pos . x , exp . pos . y , exp . pos . z , exp . radius + 120 ) ;
341+ colorUniform . set ( exp . color . r , exp . color . g , exp . color . b ) ;
342+ } else {
343+ posUniform . set ( 0 , 0 , 0 , 0 ) ;
344+ colorUniform . set ( 0 , 0 , 0 ) ;
345+ }
346+ }
347+ }
223348
224349function resetSupernova ( ) { supernova . state = 'IDLE' ; supernova . charge = 0 ; supernova . explosionTime = 0 ; supernova . epicenter . set ( 0 , 0 , 0 ) ; }
225350function resetBattle ( ) {
@@ -381,9 +506,8 @@ function updateBeam(fighter, enemy) {
381506 const dx = beam . head . x - enemy . pos . x ;
382507 const dy = beam . head . y - enemy . pos . y ;
383508 const dz = beam . head . z - enemy . pos . z ;
384- const dist = Math . sqrt ( dx * dx + dy * dy + dz * dz ) ;
385509 const hitRadius = ( enemy . shielding && enemy . shieldStrength > 0.25 ) ? BATTLE_SHIELD_RADIUS : BATTLE_HIT_RADIUS ;
386- if ( dist < hitRadius ) {
510+ if ( dx * dx + dy * dy + dz * dz < hitRadius * hitRadius ) {
387511 if ( enemy . shielding && enemy . shieldStrength > 0.25 ) {
388512 spawnExplosion ( beam . head , { r : 0.4 , g : 0.8 , b : 1.0 } , 0.6 ) ;
389513 finishBattleBeam ( beam , beam . head , true ) ;
@@ -414,8 +538,7 @@ function updateBeam(fighter, enemy) {
414538 const dx = beam . head . x - otherBeam . head . x ;
415539 const dy = beam . head . y - otherBeam . head . y ;
416540 const dz = beam . head . z - otherBeam . head . z ;
417- const dist = Math . sqrt ( dx * dx + dy * dy + dz * dz ) ;
418- if ( dist < BATTLE_HIT_RADIUS ) {
541+ if ( dx * dx + dy * dy + dz * dz < BATTLE_HIT_RADIUS * BATTLE_HIT_RADIUS ) {
419542 const midX = ( beam . head . x + otherBeam . head . x ) * 0.5 ;
420543 const midY = ( beam . head . y + otherBeam . head . y ) * 0.5 ;
421544 const midZ = ( beam . head . z + otherBeam . head . z ) * 0.5 ;
@@ -726,6 +849,7 @@ window.setShape = (shape) => {
726849 const btn = document . querySelector ( '#ui button[data-mode="' + shape + '"]' ) ;
727850 if ( btn ) btn . classList . add ( 'active' ) ;
728851
852+ setActiveParticleTarget ( shape ) ;
729853 for ( let i = 0 ; i < PARTICLE_COUNT * 3 ; i ++ ) pVel [ i ] *= 0.1 ;
730854
731855 if ( isBattleMode ) flashHud ( '⚔ BATTLE MODE' ) ;
@@ -1038,7 +1162,6 @@ let time = 0;
10381162function animate ( ) {
10391163 requestAnimationFrame ( animate ) ;
10401164 time += 0.01 ;
1041- material . color . setHex ( 0xffffff ) ;
10421165
10431166 updateAudioAnalysis ( ) ;
10441167 updateCombo ( ) ;
@@ -1050,16 +1173,18 @@ function animate() {
10501173 const isBattleMode = currentShape === 'battle' ;
10511174 const hasTwoHands = hands . left . active && hands . right . active ;
10521175 const handMotion = hasTwoHands ? clamp ( ( hands . left . speed + hands . right . speed ) / 36 , 0 , 1 ) : 0 ;
1176+ const activeCount = syncActiveParticleCount ( ) ;
1177+ const handIdle = Date . now ( ) - lastHandTime > 1000 ;
10531178
1054- material . size = ( isDuelMode || isBattleMode )
1179+ particleUniforms . uPointSize . value = ( isDuelMode || isBattleMode )
10551180 ? 4.4 + handMotion * 1.4 + bassEnergy * 4.2 + beatPulse * 2.6
10561181 : 4.8 + bassEnergy * 5.2 + beatPulse * 3.0 ;
1057- material . opacity = ( isDuelMode || isBattleMode )
1182+ particleUniforms . uOpacity . value = ( isDuelMode || isBattleMode )
10581183 ? 0.82 + handMotion * 0.12 + trebleEnergy * 0.06
10591184 : 0.7 + trebleEnergy * 0.3 ;
10601185 scene . fog . density = 0.0006 + midEnergy * 0.0008 ;
10611186
1062- if ( isSwarmMode && Date . now ( ) - lastHandTime > 1000 ) {
1187+ if ( isSwarmMode && handIdle ) {
10631188 indexFingerTip . lerp ( origin , 0.05 ) ;
10641189 isPinching = false ;
10651190 }
@@ -1071,14 +1196,15 @@ function animate() {
10711196 const ax = hands . right . pos . x - hands . left . pos . x ;
10721197 const ay = hands . right . pos . y - hands . left . pos . y ;
10731198 const az = hands . right . pos . z - hands . left . pos . z ;
1074- const rawDist = Math . sqrt ( ax * ax + ay * ay + az * az ) ;
1199+ const distSq = ax * ax + ay * ay + az * az ;
10751200 const mx = ( hands . left . pos . x + hands . right . pos . x ) * 0.5 ;
10761201 const my = ( hands . left . pos . y + hands . right . pos . y ) * 0.5 ;
10771202 const mz = ( hands . left . pos . z + hands . right . pos . z ) * 0.5 ;
10781203 if ( supernova . state !== 'EXPLODING' && supernova . state !== 'COOLDOWN' ) {
1079- if ( rawDist < SUPERNOVA_DETONATE_DIST ) {
1204+ if ( distSq < SUPERNOVA_DETONATE_DIST * SUPERNOVA_DETONATE_DIST ) {
10801205 triggerSupernova ( positions , colors , new THREE . Vector3 ( mx , my , mz ) ) ;
1081- } else if ( rawDist < SUPERNOVA_TRIGGER_DIST ) {
1206+ } else if ( distSq < SUPERNOVA_TRIGGER_DIST * SUPERNOVA_TRIGGER_DIST ) {
1207+ const rawDist = Math . sqrt ( distSq ) ;
10821208 supernova . state = 'CHARGING' ;
10831209 supernova . charge = 1 - ( rawDist / SUPERNOVA_TRIGGER_DIST ) ;
10841210 supernova . epicenter . set ( mx , my , mz ) ;
@@ -1096,7 +1222,7 @@ function animate() {
10961222
10971223 const timeScale = time * 3.0 ;
10981224
1099- for ( let i = 0 ; i < PARTICLE_COUNT ; i ++ ) {
1225+ for ( let i = 0 ; i < activeCount ; i ++ ) {
11001226 const ix = i * 3 , iy = i * 3 + 1 , iz = i * 3 + 2 ;
11011227 let px = positions [ ix ] , py = positions [ iy ] , pz = positions [ iz ] ;
11021228 let vx = pVel [ ix ] , vy = pVel [ iy ] , vz = pVel [ iz ] ;
@@ -1399,7 +1525,7 @@ function animate() {
13991525 }
14001526 }
14011527 } else if ( isSwarmMode ) {
1402- if ( Date . now ( ) - lastHandTime > 1000 ) {
1528+ if ( handIdle ) {
14031529 const distSq = px * px + py * py + pz * pz ;
14041530 const dist = Math . sqrt ( distSq ) || 1 ;
14051531 const orbitSpeed = 8.0 / Math . pow ( dist , 0.4 ) ;
@@ -1487,6 +1613,7 @@ function animate() {
14871613 pVel [ ix ] = vx ; pVel [ iy ] = vy ; pVel [ iz ] = vz ;
14881614 }
14891615
1616+ updateParticleUniforms ( ) ;
14901617 particles . geometry . attributes . position . needsUpdate = true ;
14911618 particles . geometry . attributes . color . needsUpdate = true ;
14921619
0 commit comments