Skip to content

Commit 9bd71d0

Browse files
committed
refactor: replace PointsMaterial with custom ShaderMaterial to support dynamic particle energy and explosion effects
1 parent 6a5ee98 commit 9bd71d0

1 file changed

Lines changed: 144 additions & 17 deletions

File tree

app.min.js

Lines changed: 144 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const DEVICE_THREAD_HINT = navigator.hardwareConcurrency || 4;
77
const DEVICE_MEMORY_HINT = navigator.deviceMemory || 4;
88
const PERF_TIER = Math.min(2, DEVICE_THREAD_HINT / 4) * Math.min(1.5, DEVICE_MEMORY_HINT / 4);
99
const PARTICLE_COUNT = PERF_TIER >= 1.4 ? 20000 : (PERF_TIER >= 0.9 ? 16000 : 12000);
10+
const BATTLE_PARTICLE_SCALE = 0.65;
1011
const SWARM_FORCE = 0.5;
1112
const SHAPE_FORCE = 0.2;
1213
const DAMPING = 0.96;
@@ -32,12 +33,15 @@ const COLOR_FLASH = { r: 1.0, g: 1.0, b: 1.0 };
3233
const COLOR_BATTLE = { r: 1.0, g: 0.9, b: 0.2 };
3334
const RENDER_PIXEL_RATIO = Math.min(window.devicePixelRatio || 1, PERF_TIER >= 1.4 ? 1.15 : 1.0);
3435
const HAND_PERSIST_MS = 400; // keep hand "active" for 400ms after loss
36+
const MAX_GPU_EXPLOSIONS = 4;
3537

3638
// --- State ---
3739
let currentShape = 'swarm';
3840
let width = window.innerWidth;
3941
let height = window.innerHeight;
4042
let isSwarmMode = true;
43+
let activeParticleCount = PARTICLE_COUNT;
44+
let targetActiveParticleCount = PARTICLE_COUNT;
4145
let isPinching = false;
4246
let pinchDistance = 0;
4347
let handCenter = new THREE.Vector3();
@@ -179,11 +183,97 @@ for (let i = 0; i < PARTICLE_COUNT; i++) {
179183

180184
geometry.setAttribute('position', new THREE.BufferAttribute(pPos, 3));
181185
geometry.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
});
188278
const particles = new THREE.Points(geometry, material);
189279
scene.add(particles);
@@ -220,6 +310,41 @@ const origin = new THREE.Vector3(0, 0, 0);
220310
const singleForceOut = { vx: 0, vy: 0, vz: 0 };
221311
function clamp(v, mn, mx) { return Math.max(mn, Math.min(mx, v)); }
222312
function 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

224349
function resetSupernova() { supernova.state = 'IDLE'; supernova.charge = 0; supernova.explosionTime = 0; supernova.epicenter.set(0, 0, 0); }
225350
function 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;
10381162
function 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

Comments
 (0)