// ctrl_camera_pr_env.js // ============================================================================ // FREE CAMERA + COLLISION ROBUSTE (type FPS léger) pour PlayCanvas (sans Ammo) // - Souris : rotation (look around) // - ZQSD / Flèches : déplacement local (avant/arrière & strafe) // - Molette / Pinch : dolly (avance/recul le long du regard) // - Collisions : sphère (caméra) vs AABBs "épaissies & fusionnées" du GLB (focusEntity) // - Déplacements "swept" (sous-division en pas) pour éviter le tunneling // - minY + BBox globale optionnelle (Xmin..Zmax) restent actives // - Conserve les noms publics "orbitCamera*" pour compatibilité avec l'existant // ============================================================================ var FreeCamera = pc.createScript('orbitCamera'); // garder le nom public "orbitCamera" // ======================== Attributs =========================== FreeCamera.attributes.add('inertiaFactor', { type: 'number', default: 0.12, title: 'Inertia (rotation)' }); // Limites de pitch FreeCamera.attributes.add('pitchAngleMin', { type: 'number', default: -89, title: 'Pitch Min (deg)' }); FreeCamera.attributes.add('pitchAngleMax', { type: 'number', default: 89, title: 'Pitch Max (deg)' }); // minY (altitude min du point CAMÉRA) FreeCamera.attributes.add('minY', { type: 'number', default: 0, title: 'Minimum camera Y' }); // Vitesse de déplacement (m/s) FreeCamera.attributes.add('moveSpeed', { type: 'number', default: 2.2, title: 'Move Speed' }); FreeCamera.attributes.add('strafeSpeed', { type: 'number', default: 2.2, title: 'Strafe Speed' }); FreeCamera.attributes.add('dollySpeed', { type: 'number', default: 2.0, title: 'Mouse/Pinch Dolly Speed' }); // Collision (caméra sphère) FreeCamera.attributes.add('collisionRadius', { type: 'number', default: 0.30, title: 'Camera Sphere Radius' }); FreeCamera.attributes.add('collisionEpsilon', { type: 'number', default: 0.001, title: 'Collision Epsilon' }); // Sous-division du déplacement (swept) FreeCamera.attributes.add('maxStepDistance', { type: 'number', default: 0.20, title: 'Max step distance (swept move)' }); FreeCamera.attributes.add('maxResolveIters', { type: 'number', default: 6, title: 'Max resolve iterations per step' }); // Construction des colliders FreeCamera.attributes.add('mergeGap', { type: 'number', default: 0.03, title: 'Merge AABBs gap tolerance (m)' }); FreeCamera.attributes.add('inflateBias',{ type: 'number', default: 0.02, title: 'Extra inflate beyond radius (m)' }); // Bounding Box globale (active si Xmin gd.width); }; // ======================== Colliders robustes =========================== FreeCamera.prototype._buildRobustColliders = function () { this._colliders = []; // { aabb: BoundingBox } this._worldAabb = null; // BoundingBox globale du décor (utile debug / clamp) if (!this.focusEntity) return; // 1) Récupère toutes les AABBs world des meshInstances du GLB var tmp = []; var stack = [this.focusEntity]; while (stack.length) { var e = stack.pop(); var rc = e.render; if (rc && rc.meshInstances && rc.meshInstances.length) { for (var i = 0; i < rc.meshInstances.length; i++) { var aabb = rc.meshInstances[i].aabb; // clone tmp.push(new pc.BoundingBox(aabb.center.clone(), aabb.halfExtents.clone())); } } var ch = e.children; if (ch && ch.length) for (var c = 0; c < ch.length; c++) stack.push(ch[c]); } if (tmp.length === 0) return; // 2) Gonfle chaque AABB du rayon de la caméra + biais var inflate = Math.max(0, this.collisionRadius) + Math.max(0, this.inflateBias); for (var k = 0; k < tmp.length; k++) { var bb = tmp[k]; bb.halfExtents.add(new pc.Vec3(inflate, inflate, inflate)); } // 3) Fusionne les AABBs qui se touchent/quasi-se touchent (mergeGap) var merged = this._mergeAabbs(tmp, this.mergeGap); // 4) Enregistre for (var m = 0; m < merged.length; m++) { this._colliders.push({ aabb: merged[m] }); if (!this._worldAabb) { this._worldAabb = new pc.BoundingBox(merged[m].center.clone(), merged[m].halfExtents.clone()); } else { this._worldAabb.add(merged[m]); } } }; // Fusion simple O(n^2) jusqu’à stabilisation FreeCamera.prototype._mergeAabbs = function (boxes, gap) { var out = boxes.slice(); var changed = true; var g = Math.max(0, gap || 0); function overlapsOrTouches(a, b, tol) { var amin = a.getMin(), amax = a.getMax(); var bmin = b.getMin(), bmax = b.getMax(); return !( amax.x < bmin.x - tol || amin.x > bmax.x + tol || amax.y < bmin.y - tol || amin.y > bmax.y + tol || amax.z < bmin.z - tol || amin.z > bmax.z + tol ); } while (changed) { changed = false; var next = []; var used = new Array(out.length).fill(false); for (var i = 0; i < out.length; i++) { if (used[i]) continue; var acc = out[i]; var mergedAny = false; for (var j = i + 1; j < out.length; j++) { if (used[j]) continue; if (overlapsOrTouches(acc, out[j], g)) { // fusion : créer une nouvelle AABB couvrant acc et out[j] var accMin = acc.getMin(), accMax = acc.getMax(); var bMin = out[j].getMin(), bMax = out[j].getMax(); var nMin = new pc.Vec3( Math.min(accMin.x, bMin.x), Math.min(accMin.y, bMin.y), Math.min(accMin.z, bMin.z) ); var nMax = new pc.Vec3( Math.max(accMax.x, bMax.x), Math.max(accMax.y, bMax.y), Math.max(accMax.z, bMax.z) ); var nCenter = nMin.clone().add(nMax).mulScalar(0.5); var nHalf = nMax.clone().sub(nCenter).abs(); acc = new pc.BoundingBox(nCenter, nHalf); used[j] = true; mergedAny = true; changed = true; } } used[i] = true; next.push(acc); } out = next; } return out; }; // ======================== Contraintes génériques =========================== FreeCamera.prototype._bboxEnabled = function () { return (this.Xmin < this.Xmax) && (this.Ymin < this.Ymax) && (this.Zmin < this.Zmax); }; FreeCamera.prototype._clampPosition = function (p) { // minY prioritaire if (p.y < this.minY) p.y = this.minY; if (!this._bboxEnabled()) return; p.x = pc.math.clamp(p.x, this.Xmin, this.Xmax); p.y = pc.math.clamp(p.y, Math.max(this.Ymin, this.minY), this.Ymax); p.z = pc.math.clamp(p.z, this.Zmin, this.Zmax); }; // ======================== Mouvement swept + résolution ===================== // Déplace "de -> to" par petits pas pour éviter le tunneling. FreeCamera.prototype._moveSweptTo = function (from, to) { var maxStep = Math.max(0.01, this.maxStepDistance || 0.2); var delta = to.clone().sub(from); var dist = delta.length(); if (dist <= maxStep) { var p = from.clone().add(delta); p = this._resolveCollisions(p, this.maxResolveIters); return p; } var steps = Math.ceil(dist / maxStep); var stepVec = delta.divScalar(steps); var cur = from.clone(); for (var i = 0; i < steps; i++) { cur.add(stepVec); cur = this._resolveCollisions(cur, this.maxResolveIters); } return cur; }; // Résolution itérative sphère (caméra) vs AABBs "épaissies" FreeCamera.prototype._resolveCollisions = function (pos, maxIters) { var p = pos.clone(); var eps = Math.max(1e-7, this.collisionEpsilon); if (!this._colliders || this._colliders.length === 0) return p; var iters = Math.max(1, maxIters || 1); for (var iter = 0; iter < iters; iter++) { var moved = false; for (var i = 0; i < this._colliders.length; i++) { var aabb = this._colliders[i].aabb; // Point le plus proche de p sur l'AABB var min = aabb.getMin(); var max = aabb.getMax(); var cx = pc.math.clamp(p.x, min.x, max.x); var cy = pc.math.clamp(p.y, min.y, max.y); var cz = pc.math.clamp(p.z, min.z, max.z); var dx = p.x - cx, dy = p.y - cy, dz = p.z - cz; var distSq = dx*dx + dy*dy + dz*dz; // Comme les AABBs sont déjà "épaissies" du rayon, ici on traite une sphère ~ de rayon ~0 // => il suffit de sortir du volume (point dans AABB élargie) if (distSq < eps*eps && // p quasi dedans et très proche (p.x > min.x - eps && p.x < max.x + eps && p.y > min.y - eps && p.y < max.y + eps && p.z > min.z - eps && p.z < max.z + eps)) { // Choisir l'axe de moindre déplacement pour sortir var ex = Math.min(Math.abs(p.x - min.x), Math.abs(max.x - p.x)); var ey = Math.min(Math.abs(p.y - min.y), Math.abs(max.y - p.y)); var ez = Math.min(Math.abs(p.z - min.z), Math.abs(max.z - p.z)); if (ex <= ey && ex <= ez) { // pousser sur X if (Math.abs(p.x - min.x) < Math.abs(max.x - p.x)) p.x = min.x - eps; else p.x = max.x + eps; } else if (ey <= ex && ey <= ez) { // pousser sur Y (gère bord/nez de marche) if (Math.abs(p.y - min.y) < Math.abs(max.y - p.y)) p.y = Math.max(this.minY, min.y - eps); else p.y = max.y + eps; } else { // pousser sur Z if (Math.abs(p.z - min.z) < Math.abs(max.z - p.z)) p.z = min.z - eps; else p.z = max.z + eps; } moved = true; continue; } // Cas général : p dans l'AABB "épaissie" (ou très proche) -> projeter vers l'extérieur var inside = (p.x > min.x - eps && p.x < max.x + eps && p.y > min.y - eps && p.y < max.y + eps && p.z > min.z - eps && p.z < max.z + eps); if (inside) { var dxMin = Math.abs(p.x - min.x), dxMax = Math.abs(max.x - p.x); var dyMin = Math.abs(p.y - min.y), dyMax = Math.abs(max.y - p.y); var dzMin = Math.abs(p.z - min.z), dzMax = Math.abs(max.z - p.z); var ax = Math.min(dxMin, dxMax); var ay = Math.min(dyMin, dyMax); var az = Math.min(dzMin, dzMax); if (ax <= ay && ax <= az) { // sortir par X if (dxMin < dxMax) p.x = min.x - eps; else p.x = max.x + eps; } else if (ay <= ax && ay <= az) { // sortir par Y if (dyMin < dyMax) p.y = Math.max(this.minY, min.y - eps); else p.y = max.y + eps; } else { // sortir par Z if (dzMin < dzMax) p.z = min.z - eps; else p.z = max.z + eps; } moved = true; } } if (!moved) break; } return p; }; // ===================== INPUT SOURIS (rotation + molette dolly) ===================== var FreeCameraInputMouse = pc.createScript('orbitCameraInputMouse'); // garder le nom FreeCameraInputMouse.attributes.add('lookSensitivity', { type: 'number', default: 0.3, title: 'Look Sensitivity' }); FreeCameraInputMouse.attributes.add('wheelSensitivity',{ type: 'number', default: 1.0, title: 'Wheel Sensitivity' }); FreeCameraInputMouse.prototype.initialize = function () { this.freeCam = this.entity.script.orbitCamera; // instance FreeCamera this.last = new pc.Vec2(); this.isLooking = false; if (this.app.mouse) { this.app.mouse.on(pc.EVENT_MOUSEDOWN, this.onMouseDown, this); this.app.mouse.on(pc.EVENT_MOUSEUP, this.onMouseUp, this); this.app.mouse.on(pc.EVENT_MOUSEMOVE, this.onMouseMove, this); this.app.mouse.on(pc.EVENT_MOUSEWHEEL, this.onMouseWheel, this); this.app.mouse.disableContextMenu(); } var self = this; this._onOut = function(){ self.isLooking = false; }; window.addEventListener('mouseout', this._onOut, false); this.on('destroy', () => { if (this.app.mouse) { this.app.mouse.off(pc.EVENT_MOUSEDOWN, this.onMouseDown, this); this.app.mouse.off(pc.EVENT_MOUSEUP, this.onMouseUp, this); this.app.mouse.off(pc.EVENT_MOUSEMOVE, this.onMouseMove, this); this.app.mouse.off(pc.EVENT_MOUSEWHEEL, this.onMouseWheel, this); } window.removeEventListener('mouseout', this._onOut, false); }); }; FreeCameraInputMouse.prototype.onMouseDown = function (e) { this.isLooking = true; this.last.set(e.x, e.y); }; FreeCameraInputMouse.prototype.onMouseUp = function () { this.isLooking = false; }; FreeCameraInputMouse.prototype.onMouseMove = function (e) { if (!this.isLooking || !this.freeCam) return; var sens = this.lookSensitivity; this.freeCam.yaw = this.freeCam.yaw - e.dx * sens; this.freeCam.pitch = this.freeCam.pitch - e.dy * sens; this.last.set(e.x, e.y); }; FreeCameraInputMouse.prototype.onMouseWheel = function (e) { if (!this.freeCam) return; var cam = this.entity; var move = -e.wheelDelta * this.wheelSensitivity * this.freeCam.dollySpeed * 0.05; var forward = cam.forward.clone(); if (forward.lengthSq() > 1e-8) forward.normalize(); var from = cam.getPosition().clone(); var to = from.clone().add(forward.mulScalar(move)); var next = this.freeCam._moveSweptTo(from, to); this.freeCam._clampPosition(next); cam.setPosition(next); e.event.preventDefault(); }; // ===================== INPUT TOUCH (look + pinch dolly) ===================== var FreeCameraInputTouch = pc.createScript('orbitCameraInputTouch'); FreeCameraInputTouch.attributes.add('lookSensitivity', { type: 'number', default: 0.5, title: 'Look Sensitivity' }); FreeCameraInputTouch.attributes.add('pinchDollyFactor', { type: 'number', default: 0.02, title: 'Pinch Dolly Factor' }); FreeCameraInputTouch.prototype.initialize = function () { this.freeCam = this.entity.script.orbitCamera; this.last = new pc.Vec2(); this.isLooking = false; this.lastPinch = 0; if (this.app.touch) { this.app.touch.on(pc.EVENT_TOUCHSTART, this.onTouchStartEndCancel, this); this.app.touch.on(pc.EVENT_TOUCHEND, this.onTouchStartEndCancel, this); this.app.touch.on(pc.EVENT_TOUCHCANCEL, this.onTouchStartEndCancel, this); this.app.touch.on(pc.EVENT_TOUCHMOVE, this.onTouchMove, this); } this.on('destroy', () => { if (this.app.touch) { this.app.touch.off(pc.EVENT_TOUCHSTART, this.onTouchStartEndCancel, this); this.app.touch.off(pc.EVENT_TOUCHEND, this.onTouchStartEndCancel, this); this.app.touch.off(pc.EVENT_TOUCHCANCEL, this.onTouchStartEndCancel, this); this.app.touch.off(pc.EVENT_TOUCHMOVE, this.onTouchMove, this); } }); }; FreeCameraInputTouch.prototype.onTouchStartEndCancel = function (e) { var t = e.touches; if (t.length === 1) { this.isLooking = (e.event.type === 'touchstart'); this.last.set(t[0].x, t[0].y); } else if (t.length === 2) { var dx = t[0].x - t[1].x; var dy = t[0].y - t[1].y; this.lastPinch = Math.sqrt(dx*dx + dy*dy); } else { this.isLooking = false; } }; FreeCameraInputTouch.prototype.onTouchMove = function (e) { var t = e.touches; if (!this.freeCam) return; if (t.length === 1 && this.isLooking) { var sens = this.lookSensitivity; var dx = t[0].x - this.last.x; var dy = t[0].y - this.last.y; this.freeCam.yaw = this.freeCam.yaw - dx * sens; this.freeCam.pitch = this.freeCam.pitch - dy * sens; this.last.set(t[0].x, t[0].y); } else if (t.length === 2) { // Pinch dolly var dx = t[0].x - t[1].x; var dy = t[0].y - t[1].y; var dist = Math.sqrt(dx*dx + dy*dy); var delta = dist - this.lastPinch; this.lastPinch = dist; var cam = this.entity; var forward = cam.forward.clone(); if (forward.lengthSq() > 1e-8) forward.normalize(); var from = cam.getPosition().clone(); var to = from.clone().add(forward.mulScalar(delta * this.pinchDollyFactor * this.freeCam.dollySpeed)); var next = this.freeCam._moveSweptTo(from, to); this.freeCam._clampPosition(next); cam.setPosition(next); } }; // ===================== INPUT CLAVIER (ZQSD + flèches) ===================== var FreeCameraInputKeyboard = pc.createScript('orbitCameraInputKeyboard'); FreeCameraInputKeyboard.attributes.add('acceleration', { type: 'number', default: 1.0, title: 'Accel (unused, future)' }); FreeCameraInputKeyboard.prototype.initialize = function () { this.freeCam = this.entity.script.orbitCamera; this.kb = this.app.keyboard || null; }; FreeCameraInputKeyboard.prototype.update = function (dt) { if (!this.freeCam || !this.kb) return; // Déplacements : flèches OU ZQSD (AZERTY) var fwd = (this.kb.isPressed(pc.KEY_UP) || this.kb.isPressed(pc.KEY_Z)) ? 1 : (this.kb.isPressed(pc.KEY_DOWN) || this.kb.isPressed(pc.KEY_S)) ? -1 : 0; var strf = (this.kb.isPressed(pc.KEY_RIGHT) || this.kb.isPressed(pc.KEY_D)) ? 1 : (this.kb.isPressed(pc.KEY_LEFT) || this.kb.isPressed(pc.KEY_Q)) ? -1 : 0; if (fwd !== 0 || strf !== 0) { var cam = this.entity; var from = cam.getPosition().clone(); var forward = cam.forward.clone(); if (forward.lengthSq() > 1e-8) forward.normalize(); var right = cam.right.clone(); if (right.lengthSq() > 1e-8) right.normalize(); var delta = new pc.Vec3(); delta.add(forward.mulScalar(fwd * this.freeCam.moveSpeed * dt)); delta.add(right .mulScalar(strf * this.freeCam.strafeSpeed * dt)); var to = from.clone().add(delta); var next = this.freeCam._moveSweptTo(from, to); this.freeCam._clampPosition(next); cam.setPosition(next); } // Rotation au clavier (Shift + flèches) var shift = this.kb.isPressed(pc.KEY_SHIFT); if (shift) { var yawDir = (this.kb.isPressed(pc.KEY_LEFT) ? 1 : 0) - (this.kb.isPressed(pc.KEY_RIGHT) ? 1 : 0); // ← CCW / → CW var pitchDir = (this.kb.isPressed(pc.KEY_UP) ? 1 : 0) - (this.kb.isPressed(pc.KEY_DOWN) ? 1 : 0); // ↑ up / ↓ down var yawSpeed = 120; // deg/s var pitchSpeed = 90; // deg/s if (yawDir !== 0) this.freeCam.yaw = this.freeCam.yaw + yawDir * yawSpeed * dt; if (pitchDir !== 0) this.freeCam.pitch = this.freeCam.pitch + pitchDir * pitchSpeed * dt; } };