... sliders count, sep, ali, coh, perception radius, max speed, mouse force.
Boids data structures: - N configurable via slider. To support dynamic count, we can allocate max and update count. But simpler: fixed count set by slider; on change reinitialize arrays and instanced mesh. Use max 6000? Reinit okay. - Use Float32Arrays for px, py, pz, vx, vy, vz, ax, ay, az. - Maybe use Float32Arrays for steering accumulators. Spatial grid: - World dimensions: width=2000, height=1200, depth=800. Cell size = perception radius (maybe 50). Number of cells: (2000/50)=40, (1200/50)=24, (800/50)=16 => 15360 cells. Storing array per cell as JS array of variable length; clearing each frame could be costly (15k arrays). Alternative: use a flat grid with linked list (next array, head array). Each boid assigned to a cell; head[cell] = boid index; next[boid] = previous head. This is O(N). For neighbor query, iterate cell lists. This is efficient. - Compute cell index from position: cx = floor((x + halfW)/cellSize), clamp 0..CX-1. Similar cy, cz. cell = (cz*CY + cy)*CX + cx. - Head array length = CX*CY*CZ, initialized -1. Next array length N. - Each frame: reset head to -1. For i in 0..N-1: compute cell, next[i]=head[cell], head[cell]=i. - For each boid, query neighbor cells in range [-1,0,1] in each dimension. For each neighbor j, compute squared distance < radius^2; accumulate separation, alignment, cohesion. Forces: - separation: steer away from close boids within sepRadius (e.g., 30). Sum (p - pj) normalized? Actually separation vector = (p - pj)/dist^2 maybe. Use scaled by inverse distance. - alignment: average velocity of neighbors within alignRadius (perception). Steer towards avg - velocity. - cohesion: average position of neighbors within cohRadius. Steer towards avgPos - p. - We can use single perception radius for all for efficiency? User wants separation/alignment/cohesion. We can apply same neighbor radius for align/coh and smaller radius for separation. Compute both in one loop: if dist < sepRadius, accumulate separation; if dist < perception, accumulate alignment & cohesion. This is efficient. - Apply weights from UI. - Limit force magnitude maxForce. - Update velocity: v += a. Limit speed to maxSpeed. Position += v. Wrap boundaries. - Mouse: if mouse down, compute mouse world pos. For each boid within mouseRadius, apply strong repulsive force away from mouse, scaled by inverse distance. Also maybe if not down, mouse acts as attractor? User says drag to disturb. We'll do scare repulsion while mouse is down or moving. Could also apply when pointer is moving (dragging). Use pointerdown/up/move; set mouseActive when pointer down. Or also when pointer moves without button? "drag the mouse" implies button. Use isMouseDown. On move compute ray intersection with z=0 plane. - Orientation: For each boid, construct orientation matrix from velocity. Use dummy object: look from position towards position + velocity, up = (0,1,0). But if velocity nearly parallel to up, use another up. Use dummy.lookAt(p.x+v.x, p.y+v.y, p.z+v.z); dummy.position.set(p.x,p.y,p.z); dummy.updateMatrix(); mesh.setMatrixAt(i, dummy.matrix). For thousands, this per-boid matrix computation with Euler is somewhat heavy but okay for 3000. Could manually build matrix from orthonormal basis to avoid lookAt overhead. But likely fine. However lookAt for 3000 each frame might be heavy. Let's implement manual basis for performance. Manual orientation matrix: - forward = normalize(v). If |v| small, default forward (1,0,0). Choose world up = (0,0,1) or (0,1,0). For bird orientation, geometry should point along +X? Let's use ConeGeometry with rotation to point +X? Actually default Cone points +Y. We can rotate geometry in constructor so local forward is +X? Simpler: use ConeGeometry(radius, length, 5) and rotate -PI/2 around Z so it points +X. Then orientation matrix columns: right = forward, up = some vector perpendicular, direction cross. But if forward aligns with world up, degenerate. - Let's choose worldUp = (0,0,1). If abs(forward.z) > 0.99, use worldUp=(0,1,0). side = normalize(cross(worldUp, forward)); up = cross(forward, side). Matrix columns: m[0]=forward.x,... m[4]=up.x? Wait standard Three.js matrix: elements column-major. For Object3D matrix, basis vectors are columns: column0 = right (x axis), column1 = up, column2 = forward (z axis)? Actually in Three.js, object matrixWorld transforms local coords; default camera looks down -Z. For a mesh, if we want cone pointing along velocity, easiest is to set matrix with x-axis = forward. Then geometry local X axis points along cone tip. So set column0 = forward, column1 = up, column2 = side. That should orient the cone along forward. - Build matrix: elements[0]=fx; [4]=ux; [8]=sx; [12]=px; [1]=fy; [5]=uy; [9]=sy; [13]=py; [2]=fz; [6]=uz; [10]=sz; [14]=pz; [3]=[7]=[11]=0; [15]=1. Where up and side computed. But we need geometry pointing along +X. ConeGeometry default orientation is +Y (apex at top along +Y). We can rotate geometry at creation: geometry.rotateZ(-Math.PI/2) so +Y becomes +X. Then local X axis is apex. Good. Alternatively use a simple custom BufferGeometry triangle/bird shape. Cone is fine. InstancedMesh count: use maxCount fixed? If slider changes count, reinitialize. Use material color maybe based on speed? Could use instanceColor. Not necessary; use cyan/white with additive blending? Use MeshBasicMaterial with additive blending and low opacity to create trails? But InstancedMesh with transparent additive can look good. However depth sorting issues. Use color #4fd, additiveBlending, depthWrite false. Or use points? But user wants birds. Cone instanced. Performance: - Use one neighbor query per boid with spatial grid. - Use Float32Arrays. - Use requestAnimationFrame. - Avoid creating objects per frame. Potential issue: spatial grid cell size. Use perception radius from UI. If cell size changes, need to recompute grid dims. We can set cellSize = perception radius (or slightly larger). But if perception radius changes, recompute grid dims each frame? Could be okay. Simpler: use fixed cellSize = 50, and query cells within ceil(perception/cellSize). But if perception large, query more cells. Use cellSize = 40 fixed. Grid dims based on world size / cellSize. Perception radius UI maybe 30-100. Query range = ceil(perception/cellSize). This keeps grid stable. Use separation radius = 0.5*perception? Or separate? UI has only perception radius maybe. We can expose sliders for separation/alignment/cohesion weights, max speed, perception. Separation uses 0.4*perception or fixed 25. Let's use sepRadius = max(20, perception*0.4). Align/coh use perception. But UI might be simpler: sliders for count, perception, separation, alignment, cohesion, maxSpeed, mouseForce. Good. World bounds: we need to wrap positions. Use halfW=worldW/2. If x > halfW, x -= worldW; if x < -halfW, x += worldW. Same for y,z. But if z depth 800, halfD=400. Camera at z=600 looking origin. Boids behind camera may not be visible. Wrap z between -400 and 400. But camera at z=600, looking at z=0, so visible z range maybe -500..500. Fine. Actually if camera at (0,0,600) and looking origin, boids at z >600 are behind camera; wrap ensures not. Mouse raycast: Use raycaster with a plane at z=0 (normal z). On pointer move, set raycaster from camera, intersect plane, get point. Keep mouseWorld. If pointer down, mouseActive=true. Use mouseWorld only if active. Force application: For each boid, compute vector to mouseWorld, dist. If dist < mouseRadius, apply repulsion: dx=px-mx, etc. inv = 1/dist? Actually avoid division by zero. Strength = mouseForce * (1 - dist/mouseRadius). Add normalized away * strength. This scares. Potential issue: mouse pointer not moving but down remains; still apply force. Good. Rendering: Use perspective camera. Add subtle grid helper? Maybe not. Use dark background. Add bloom? Not needed. UI styling: overlay top-left with sliders. Update values displayed. Avoid blocking view. Now code details. Initialization: - const MAX_N = parseInt(countSlider.value). But if slider changes, reinit. Allocate arrays size N. - create geometry: cone radius=3, height=12, segments=4? Low poly. rotateZ(-PI/2). Scale maybe (1.5,0.6,0.6). Use InstancedMesh(geometry, material, N). Set instanceMatrix usage dynamic. - Set initial positions random within bounds, velocities random normalized * maxSpeed. Animation loop: - updateGrid(); - for i: compute forces, update velocity/position, set matrix. - mesh.instanceMatrix.needsUpdate = true; - renderer.render(scene, camera); Force computation details: Pseudo: const invCellSize = 1/CELL_SIZE; const cx0 = Math.floor((px[i] + halfW) * invCellSize); ... const range = Math.ceil(perception / CELL_SIZE); const sepRadius = Math.max(20, perception*0.4); const sepRadiusSq = sepRadius*sepRadius; const percepSq = perception*perception; let sx=0,sy=0,sz=0, sepCount=0; let ax=0,ay=0,az=0, aliCount=0; let cx_acc=0,cy_acc=0,cz_acc=0, cohCount=0; for dz=-range..range: for dy=-range..range: for dx=-range..range: const c = ... clamp for (let j=head[c]; j>=0; j=next[j]) { if (j===i) continue; const dx_=px[i]-px[j], dy_=..., dz_=...; const distSq = dx_*dx_+...; if (distSq < sepRadiusSq) { const dist = Math.sqrt(distSq); const inv = 1/dist; sx += dx_*inv; sy += dy_*inv; sz += dz_*inv; sepCount++; } if (distSq < percepSq && distSq>0) { ax += vx[j]; ay += vy[j]; az += vz[j]; cx_acc += px[j]; ...; aliCount++; cohCount++; } } Then compute steering: // separation if (sepCount>0) { // desired = avg separation vector let dlen = Math.hypot(sx,sy,sz); if (dlen>0) { sx = sx/dlen*maxSpeed - vx[i]; ... } // scale by separation weight but clamp maxForce // We can multiply by separation weight then clamp let f = Math.hypot(sx,sy,sz); if (f>maxForce) { sx = sx/f*maxForce; ... } ax_total += sx * separationWeight; } // alignment if (aliCount>0) { ax = ax/aliCount; ...; // avg velocity let dlen = Math.hypot(ax,ay,az); if (dlen>0) { ax = ax/dlen*maxSpeed - vx[i]; ... } let f = Math.hypot(ax,ay,az); if (f>maxForce) { ... } ax_total += ax * alignmentWeight; } // cohesion if (cohCount>0) { cx_acc = cx_acc/cohCount - px[i]; ...; let dlen = Math.hypot(cx_acc,cy_acc,cz_acc); if (dlen>0) { cx_acc = cx_acc/dlen*maxSpeed - vx[i]; ... } let f = Math.hypot(...); if (f>maxForce) ... ax_total += cx_acc * cohesionWeight; } // mouse if (mouseActive) { let dx=px[i]-mouse.x, dy=..., dz=...; let d2=dx*dx+...; if (d2 < mouseRadiusSq) { let dist=Math.sqrt(d2); let nx=dx/dist, ny=..., nz=...; let str = mouseForce * (1 - dist/mouseRadius); ax_total += nx*str; ... } } // boundary soft turn? We use wrap, so no. // update velocity vx[i] += ax_total; ...; limit speed. position += velocity; wrap. Optimization: Avoid repeated Math.hypot for many boids. Use sqrt only when needed (dist for separation normalization, mouse). For alignment/cohesion, use count and normalize avg velocity/vector. Potential issue: O(N * neighbor count). With perception 50, average neighbors maybe ~10-30. Good. Grid recomputation: head array length = CX*CY*CZ. Reset to -1 each frame: for (let i=0; i0: fx=vx/speed, etc. else fx=1. Build basis. Need to handle near-parallel to worldUp. Let's choose worldUp = (0,0,1). If |fz| > 0.99, worldUp=(0,1,0). side = normalize(cross(worldUp, forward)). cross(a,b) = (a.y*b.z - a.z*b.y, a.z*b.x - a.x*b.z, a.x*b.y - a.y*b.x). up = cross(forward, side). Then set matrix as described. Set matrix: mesh.setMatrixAt(i, matrix). We can create a reusable Matrix4 and set elements directly then setMatrixAt. But setMatrixAt copies? It stores into instanceMatrix array. We can directly write into mesh.instanceMatrix.array to avoid Matrix4 allocation. InstancedMesh has .instanceMatrix which is a BufferAttribute; its array is Float32Array of size N*16. We can write directly. But easier: create a Matrix4 object, set elements, then mesh.setMatrixAt(i,m). For 3000, Matrix4 creation each frame? If we reuse one Matrix4 object, setMatrixAt copies. That's fine. But we can also compute elements and write to array for speed. Let's use direct array write for performance. Matrix elements order (column-major) for a 4x4: [0]=m11, [1]=m21, [2]=m31, [3]=m41, [4]=m12, [5]=m22, [6]=m32, [7]=m42, [8]=m13, [9]=m23, [10]=m33, [11]=m43, [12]=m14, [13]=m24, [14]=m34, [15]=m44. Where columns: c0 right, c1 up, c2 forward, c3 position. We set c0=forward, c1=up, c2=side, c3=position. Because geometry points along local X. Yes. But we need side = cross(worldUp, forward) normalized. Then up = cross(forward, side). Since forward and side are unit and perpendicular, up is unit. Compute: let sx = uy*fz - uz*fy; let sy = uz*fx - ux*fz; let sz = ux*fy - uy*fx; let sLen = Math.sqrt(sx*sx+sy*sy+sz*sz); sx/=sLen;... let upx = fy*sz - fz*sy; let upy = fz*sx - fx*sz; let upz = fx*sy - fy*sx; Then write array: const off = i*16; arr[off+0]=fx; arr[off+1]=fy; arr[off+2]=fz; arr[off+3]=0; arr[off+4]=upx; arr[off+5]=upy; arr[off+6]=upz; arr[off+7]=0; arr[off+8]=sx; arr[off+9]=sy; arr[off+10]=sz; arr[off+11]=0; arr[off+12]=px; arr[off+13]=py; arr[off+14]=pz; arr[off+15]=1; Wait earlier I said columns: c0=forward, c1=up, c2=side. Good. But is the cone pointing along +X after rotateZ(-PI/2)? Let's verify: default ConeGeometry axis is Y. rotateZ(-90°) maps Y to X. So local +X corresponds to original +Y (apex). So c0 (local X) should be forward. Good. Need to scale geometry: maybe geometry.scale(2,0.5,0.5) after rotate? But scaling geometry is okay. Use ConeGeometry(2, 10, 4) and geometry.rotateZ(-PI/2). Maybe scale not needed. Color: Use MeshBasicMaterial({ color: 0x4dd2ff, blending: THREE.AdditiveBlending, depthWrite:false, transparent:true, opacity:0.85 }). But additive blending with dark background and opaque-ish color. Could set opacity 0.6. Or use Standard material with emissive color. Additive looks like glowing birds. Camera: PerspectiveCamera(60, aspect, 1, 5000). Position (0,0,900). Look at origin. Since world height 1200, at z=900, vertical FOV 60 => visible height ~ 2*900*tan(30°) = 1039. Slightly less than world height; okay. Maybe position z=1100 for full height. Or use world height to fit: cameraZ = (worldH/2) / tan(fov/2). worldH=1200, fov=60 => tan30=0.577 => 600/0.577=1039. Use z=1050. Good. World width 2000, aspect ~1.78, horizontal FOV ~96°, visible width = 2*1050*tan(48°)=2335 >2000. Good. Responsive: on resize update camera aspect and renderer size. UI controls: - Count slider 500-6000 step 500, default 2500. - Perception 20-100 step 1 default 50. - Separation 0-5 step 0.1 default 1.5. - Alignment 0-5 step 0.1 default 1.0. - Cohesion 0-5 step 0.1 default 1.0. - MaxSpeed 5-20 step 0.5 default 10. - MouseForce 0-50 step 1 default 15. - MouseRadius 50-300 step 10 default 150. Labels update value text. Reinitialize on count change. Other params dynamic. Need to ensure first frame visible. On init, create mesh and render once before loop. Use animate. Potential issue: if N=6000, arrays and grid okay. neighbor queries maybe heavy but okay. Need to consider z wrap: if boid wraps while camera sees only near z=0, fine. Mouse raycast plane: create a Plane(new THREE.Vector3(0,0,1), 0). Actually Plane normal (0,0,1), constant 0 means z=0 plane. Use raycaster.ray.intersectPlane(plane, target). target vector reused. If camera points -Z, ray intersects plane at some point. Good. Or compute unproject. Raycaster simpler. Pointer events: - const raycaster = new THREE.Raycaster(); - const mouseNDC = new THREE.Vector2(); - const plane = new THREE.Plane(new THREE.Vector3(0,0,1),0); - const mouseWorld = new THREE.Vector3(); - let isMouseDown=false; - window.addEventListener('pointerdown', e=>{isMouseDown=true; updateMouse(e);}); - pointerup => isMouseDown=false; - pointermove => updateMouse(e); function updateMouse(e) { mouseNDC.x=(e.clientX/window.innerWidth)*2-1; mouseNDC.y=-(e.clientY/window.innerHeight)*2+1; raycaster.setFromCamera(mouseNDC,camera); raycaster.ray.intersectPlane(plane,mouseWorld); } Apply mouse force only if isMouseDown. Maybe also if pointermove with button not down? "drag" implies down. We'll do down. Now code structure. Potential memory/performance: spatial grid head array length maybe large. World W=2000,H=1200,D=800, cell=40 => 50*30*20=30000 cells. head fill 30k each frame fine. For N=6000, next array 6000. For each boid query ~27 cells average, each cell maybe few boids. Good. Cell size fixed 40. But if perception < cell size, range=1. Fine. If perception=100, range=3. Good. Separation radius: 0.5*perception? But typical separation has smaller radius. Use 0.4*perception clamp min 20. If perception=50, sepRadius=20. Good. Could also use fixed 25. Better to make sep radius = perception * 0.5 maybe. Let's set sepRadius = perception * 0.5. UI weights control strength. With perception 50, separation radius 25. Good. MaxForce: maybe 0.3*maxSpeed. Use 0.3. Steering calculation: For each rule, compute desired velocity = normalized vector * maxSpeed - current velocity. Limit to maxForce. Then multiply by weight. But if we limit before weight, weight can exceed maxForce. Alternatively multiply by weight then limit total. Let's compute normalized desired, subtract velocity, clamp to maxForce*weight? Hmm. Standard boids: steering = desired - velocity; limit steering to maxForce; then apply weight. So if weight >1, force can exceed maxForce. That's okay. We'll compute ruleSteer, clamp to maxForce, then multiply by weight. Then total acceleration = sum of weighted steering + mouse. Then clamp total acceleration to maybe 2*maxForce? Not strictly needed. Update velocity with acceleration, clamp speed. But for separation using inverse distance, we can compute desired = avg separation vector normalized * maxSpeed - velocity. Good. Mouse force not clamped but str computed. Boundary wrap: after position update, wrap. But if perception across wrap boundaries, spatial grid handles positions; boids near edges don't see neighbors on opposite side because grid cells at boundaries not cyclic. Could ignore or implement ghost cells. For visual, maybe soft boundaries turn back instead of wrap to avoid edge discontinuities. Soft boundaries: if x > halfW-100, steer left; if x < -halfW+100, steer right. This avoids wrap issues and keeps flock within view. But boids may pile at edges. Wrap is okay for emergent behavior. However mouse interaction near edges fine. Use wrap but maybe also a weak centering force? Could add mild centering to keep visible. But wrap is classic. Let's use soft turning boundaries rather than wrap to keep within view and avoid edge artifacts. Implement: const margin = 200; if (px > halfW - margin) ax_total -= turnFactor * (px - (halfW-margin))/margin; if (px < -halfW + margin) ax_total += turnFactor * ((-halfW+margin) - px)/margin; Similarly y,z. turnFactor maybe 1.0. This gently turns back. It also avoids needing wrap. Use half bounds. This is simple and no edge issues. It may create boundary effects but fine. World dimensions: halfW=1000, halfH=600, halfD=400. margin 200 for z maybe 100. Use turnFactor = 1.0. But if maxSpeed high, boids might still hit bounds; turn factor scales near edge. Good. Mouse repulsion may push boids to edges; boundary turn keeps them in. Use soft boundaries instead of wrap. For position, clamp? Let position update; if beyond, maybe reflect? Soft turn should keep within. Could also clamp to bounds after update to prevent escape. Add: if (px>halfW) px=halfW; if (px<-halfW) px=-halfW; etc. Now code. Potential issue: spatial grid with soft boundaries: positions always within bounds, good. Now let's write the code. Need to be careful with module import from CDN. Use import map? Simpler: import * as THREE from 'url'. Let's craft final HTML. Ensure no markdown fences, no preface. Just raw HTML. Testing mentally for syntax errors. Let's write JavaScript in