Spaces:
Running
Running
| // viewer_pr_env.js — Ammo Physics + Box Colliders from GLB AABBs | |
| // ============================================================================ | |
| // - Charge PlayCanvas (ESM) | |
| // - Charge Ammo (WASM) avec fallback JS et timeout (anti “écran noir”) | |
| // - Instancie le GLB d’environnement et génère des colliders ‘box’ statiques | |
| // en lisant les AABB des meshInstances (aucun asset collision requis) | |
| // - Crée un “player” capsule dynamique et attache la caméra en enfant (yeux) | |
| // - Conserve GSplat (.sog) si fourni | |
| // - Logs détaillés pour le debug | |
| // ============================================================================ | |
| /* ------------------------------------------- | |
| Surface d’erreurs globale (utile sur HF/iframes) | |
| -------------------------------------------- */ | |
| window.addEventListener('error', e => { | |
| console.error('[BOOT] Uncaught error:', e.message || e.error, e); | |
| }); | |
| window.addEventListener('unhandledrejection', e => { | |
| console.error('[BOOT] Unhandled promise rejection:', e.reason); | |
| }); | |
| console.log('[BOOT] JS boot reached'); | |
| /* ------------------------------------------- | |
| Utils | |
| -------------------------------------------- */ | |
| function hexToRgbaArray(hex) { | |
| try { | |
| hex = String(hex || "").replace("#", ""); | |
| if (hex.length === 6) hex += "FF"; | |
| if (hex.length !== 8) return [1, 1, 1, 1]; | |
| const num = parseInt(hex, 16); | |
| return [ | |
| ((num >> 24) & 0xff) / 255, | |
| ((num >> 16) & 0xff) / 255, | |
| ((num >> 8) & 0xff) / 255, | |
| (num & 0xff) / 255 | |
| ]; | |
| } catch (e) { | |
| console.warn("hexToRgbaArray error:", e); | |
| return [1, 1, 1, 1]; | |
| } | |
| } | |
| function traverse(entity, callback) { | |
| callback(entity); | |
| if (entity.children && entity.children.length) { | |
| entity.children.forEach((child) => traverse(child, callback)); | |
| } | |
| } | |
| function isMobileUA() { | |
| const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); | |
| const isAndroid = /Android/i.test(navigator.userAgent); | |
| return isIOS || isAndroid; | |
| } | |
| // Patch global Image -> force CORS | |
| (function () { | |
| const OriginalImage = window.Image; | |
| window.Image = function (...args) { | |
| const img = new OriginalImage(...args); | |
| img.crossOrigin = "anonymous"; | |
| return img; | |
| }; | |
| })(); | |
| /* ------------------------------------------- | |
| Chargement Ammo (WASM + fallback JS) avec timeout | |
| -------------------------------------------- */ | |
| async function loadAmmoWithFallback(pc, baseUrl = "https://playcanvas.github.io/examples/lib/ammo/", timeoutMs = 5000) { | |
| try { | |
| pc.WasmModule.setConfig("Ammo", { | |
| glueUrl: baseUrl + "ammo.wasm.js", | |
| wasmUrl: baseUrl + "ammo.wasm.wasm", | |
| fallbackUrl: baseUrl + "ammo.js" | |
| }); | |
| const p = pc.WasmModule.getInstance("Ammo", baseUrl + "ammo.wasm.js"); | |
| if (p && typeof p.then === "function") { | |
| await Promise.race([ | |
| p, | |
| new Promise((_, rej) => setTimeout(() => rej(new Error("Ammo load timeout (promise)")), timeoutMs)) | |
| ]); | |
| console.log("[Ammo] WASM ready (promise API)."); | |
| return true; | |
| } | |
| // Callback API (rarement nécessaire) | |
| await Promise.race([ | |
| new Promise((resolve) => pc.WasmModule.getInstance("Ammo", resolve)), | |
| new Promise((_, rej) => setTimeout(() => rej(new Error("Ammo load timeout (callback)")), timeoutMs)) | |
| ]); | |
| console.log("[Ammo] JS fallback ready (callback API)."); | |
| return true; | |
| } catch (e) { | |
| console.warn("[Ammo] load failed:", e); | |
| return false; | |
| } | |
| } | |
| /* ------------------------------------------- | |
| State (module / instance) | |
| -------------------------------------------- */ | |
| let pc; | |
| export let app = null; | |
| let playerEntity = null; // capsule dynamique (rigidbody) | |
| let cameraEntity = null; // caméra enfant | |
| let modelEntity = null; // GSplat principal (optionnel) | |
| let envEntity = null; // GLB instancié (environnement render) | |
| let viewerInitialized = false; | |
| let resizeObserver = null; | |
| let resizeTimeout = null; | |
| // Config / paramètres courants | |
| let sogUrl, glbUrl, presentoirUrl; | |
| let color_bg_hex, color_bg; | |
| let espace_expo_bool; | |
| // Camera spawn | |
| let chosenCameraX, chosenCameraY, chosenCameraZ; | |
| // DPR / perf | |
| let maxDevicePixelRatio = 1.75; | |
| let interactDpr = 1.0; | |
| let idleRestoreDelay = 350; | |
| let idleTimer = null; | |
| // Physique | |
| let physicsEnabled = true; // si Ammo indisponible -> false | |
| let ammoBaseUrl = "https://playcanvas.github.io/examples/lib/ammo/"; | |
| let freeFly = false; // si true => gravity zero (option) | |
| /* ------------------------------------------- | |
| Script FPS minimal (yaw/pitch + ZQSD) | |
| (nom = 'orbitCamera' pour compat) | |
| -------------------------------------------- */ | |
| function ensureFirstPersonScriptRegistered() { | |
| if (window.__PLY_FPS_REG__) return; | |
| window.__PLY_FPS_REG__ = true; | |
| var FPS = pc.createScript('orbitCamera'); | |
| FPS.attributes.add('cameraEntity', { type: 'entity', title: 'Camera (child)' }); | |
| FPS.attributes.add('moveSpeed', { type: 'number', default: 4.0, title: 'Move Speed (m/s)' }); | |
| FPS.attributes.add('lookSpeed', { type: 'number', default: 0.25, title: 'Look Sensitivity' }); | |
| FPS.attributes.add('pitchAngleMin', { type: 'number', default: -89, title: 'Pitch Min (deg)' }); | |
| FPS.attributes.add('pitchAngleMax', { type: 'number', default: 89, title: 'Pitch Max (deg)' }); | |
| FPS.attributes.add('freeFly', { type: 'boolean', default: false, title: 'Free Fly (no gravity)' }); | |
| FPS.prototype.initialize = function () { | |
| this.yaw = 0; | |
| this.pitch = 0; | |
| this._v = new pc.Vec3(); | |
| // init yaw/pitch depuis rotations actuelles | |
| var qCam = (this.cameraEntity || this.entity).getRotation(); | |
| var f = new pc.Vec3(); qCam.transformVector(pc.Vec3.FORWARD, f); | |
| this.yaw = Math.atan2(f.x, f.z) * pc.math.RAD_TO_DEG; | |
| var yawQ = new pc.Quat().setFromEulerAngles(0, -this.yaw, 0); | |
| var noYawQ = new pc.Quat().mul2(yawQ, qCam); | |
| var fNoYaw = new pc.Vec3(); noYawQ.transformVector(pc.Vec3.FORWARD, fNoYaw); | |
| this.pitch = pc.math.clamp(Math.atan2(-fNoYaw.y, -fNoYaw.z) * pc.math.RAD_TO_DEG, this.pitchAngleMin, this.pitchAngleMax); | |
| // pointer lock au clic gauche | |
| if (this.app.mouse) { | |
| this.app.mouse.on(pc.EVENT_MOUSEDOWN, (e) => { | |
| if (e.button === pc.MOUSEBUTTON_LEFT && document.activeElement === this.app.graphicsDevice.canvas) { | |
| const c = this.app.graphicsDevice.canvas; | |
| if (c.requestPointerLock) c.requestPointerLock(); | |
| } | |
| }, this); | |
| this.app.mouse.on(pc.EVENT_MOUSEMOVE, (e) => { | |
| if (document.pointerLockElement !== this.app.graphicsDevice.canvas) return; | |
| this.yaw -= e.dx * this.lookSpeed; | |
| this.pitch -= e.dy * this.lookSpeed; | |
| this.pitch = pc.math.clamp(this.pitch, this.pitchAngleMin, this.pitchAngleMax); | |
| }, this); | |
| } | |
| }; | |
| FPS.prototype.update = function (dt) { | |
| // yaw/pitch | |
| this.entity.setLocalEulerAngles(0, this.yaw, 0); | |
| if (this.cameraEntity) this.cameraEntity.setLocalEulerAngles(this.pitch, 0, 0); | |
| // mouvements | |
| const kb = this.app.keyboard; | |
| let fwd = 0, str = 0, up = 0; | |
| if (kb) { | |
| fwd += (kb.isPressed(pc.KEY_W) || kb.isPressed(pc.KEY_Z) || kb.isPressed(pc.KEY_UP)) ? 1 : 0; | |
| fwd -= (kb.isPressed(pc.KEY_S) || kb.isPressed(pc.KEY_DOWN)) ? 1 : 0; | |
| str += (kb.isPressed(pc.KEY_D) || kb.isPressed(pc.KEY_RIGHT)) ? 1 : 0; | |
| str -= (kb.isPressed(pc.KEY_A) || kb.isPressed(pc.KEY_Q) || kb.isPressed(pc.KEY_LEFT)) ? 1 : 0; | |
| // free-fly: E/Space monte, C/Ctrl descend | |
| if (this.freeFly) { | |
| up += (kb.isPressed(pc.KEY_E) || kb.isPressed(pc.KEY_SPACE)) ? 1 : 0; | |
| up -= (kb.isPressed(pc.KEY_C) || kb.isPressed(pc.KEY_CTRL)) ? 1 : 0; | |
| } | |
| } | |
| const body = this.entity.rigidbody; | |
| if (!body) return; | |
| // directions | |
| const f = this.entity.forward.clone(); | |
| const r = this.entity.right.clone(); | |
| if (!this.freeFly) { f.y = 0; r.y = 0; } | |
| if (f.lengthSq()>1e-8) f.normalize(); | |
| if (r.lengthSq()>1e-8) r.normalize(); | |
| // vélocité cible | |
| const target = new pc.Vec3() | |
| .add(f.mulScalar(fwd * this.moveSpeed)) | |
| .add(r.mulScalar(str * this.moveSpeed)); | |
| if (this.freeFly) target.y = up * this.moveSpeed; | |
| else target.y = body.linearVelocity.y; // conserve gravité | |
| // applique | |
| this._v.copy(body.linearVelocity); | |
| this._v.lerp(this._v, target, Math.min(1, dt*10)); | |
| body.linearVelocity = this._v; | |
| }; | |
| } | |
| /* ------------------------------------------- | |
| Initialisation principale | |
| -------------------------------------------- */ | |
| export async function initializeViewer(config, instanceId) { | |
| if (viewerInitialized) return; | |
| const mobile = isMobileUA(); | |
| console.log("[VIEWER] A: initializeViewer begin ", { instanceId }); | |
| // ---- Lecture config ---- | |
| sogUrl = config.sog_url || config.sogs_json_url || null; | |
| glbUrl = (config.glb_url !== undefined) ? config.glb_url : null; | |
| presentoirUrl = (config.presentoir_url !== undefined) ? config.presentoir_url : null; | |
| color_bg_hex = config.canvas_background !== undefined ? config.canvas_background : "#FFFFFF"; | |
| espace_expo_bool = config.espace_expo_bool !== undefined ? !!config.espace_expo_bool : false; | |
| color_bg = hexToRgbaArray(color_bg_hex); | |
| // Camera spawn — valeurs par défaut | |
| const camX = config.cameraX !== undefined ? parseFloat(config.cameraX) : 0; | |
| const camY = config.cameraY !== undefined ? parseFloat(config.cameraY) : 1.6; | |
| const camZ = config.cameraZ !== undefined ? parseFloat(config.cameraZ) : 4.0; | |
| const camXPhone = config.cameraXPhone !== undefined ? parseFloat(config.cameraXPhone) : camX; | |
| const camYPhone = config.cameraYPhone !== undefined ? parseFloat(config.cameraYPhone) : camY; | |
| const camZPhone = config.cameraZPhone !== undefined ? parseFloat(config.cameraZPhone) : camZ * 1.5; | |
| chosenCameraX = mobile ? camXPhone : camX; | |
| chosenCameraY = mobile ? camYPhone : camY; | |
| chosenCameraZ = mobile ? camZPhone : camZ; | |
| // DPR / perf | |
| if (config.maxDevicePixelRatio !== undefined) { | |
| maxDevicePixelRatio = Math.max(0.75, parseFloat(config.maxDevicePixelRatio) || maxDevicePixelRatio); | |
| } | |
| if (config.interactionPixelRatio !== undefined) { | |
| interactDpr = Math.max(0.75, parseFloat(config.interactionPixelRatio) || interactDpr); | |
| } | |
| if (config.idleRestoreDelayMs !== undefined) { | |
| idleRestoreDelay = Math.max(120, parseInt(config.idleRestoreDelayMs, 10) || idleRestoreDelay); | |
| } | |
| // Physique | |
| physicsEnabled = config.usePhysics === false ? false : true; // par défaut true | |
| freeFly = !!config.freeFly; | |
| if (config.ammoBaseUrl) { | |
| ammoBaseUrl = String(config.ammoBaseUrl).endsWith("/") ? config.ammoBaseUrl : (config.ammoBaseUrl + "/"); | |
| } | |
| // ---- Canvas / DOM ---- | |
| const canvasId = "canvas-" + instanceId; | |
| const progressDialog = document.getElementById("progress-dialog-" + instanceId); | |
| const viewerContainer = document.getElementById("viewer-container-" + instanceId); | |
| const old = document.getElementById(canvasId); | |
| if (old) old.remove(); | |
| const canvas = document.createElement("canvas"); | |
| canvas.id = canvasId; | |
| canvas.className = "ply-canvas"; | |
| canvas.style.width = "100%"; | |
| canvas.style.height = "100%"; | |
| canvas.setAttribute("tabindex", "0"); | |
| viewerContainer.insertBefore(canvas, progressDialog); | |
| // interactions de base | |
| canvas.style.touchAction = "none"; | |
| canvas.style.webkitTouchCallout = "none"; | |
| canvas.addEventListener("gesturestart", (e) => e.preventDefault()); | |
| canvas.addEventListener("gesturechange", (e) => e.preventDefault()); | |
| canvas.addEventListener("gestureend", (e) => e.preventDefault()); | |
| canvas.addEventListener("dblclick", (e) => e.preventDefault()); | |
| canvas.addEventListener("wheel", (e) => e.preventDefault(), { passive: false }); | |
| const scrollKeys = new Set(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","PageUp","PageDown","Home","End"," ","Space","Spacebar"]); | |
| let isPointerOverCanvas = false; | |
| const focusCanvas = () => canvas.focus({ preventScroll: true }); | |
| const onPointerEnter = () => { isPointerOverCanvas = true; focusCanvas(); }; | |
| const onPointerLeave = () => { isPointerOverCanvas = false; if (document.activeElement === canvas) canvas.blur(); }; | |
| const onCanvasBlur = () => { isPointerOverCanvas = false; }; | |
| canvas.addEventListener("pointerenter", onPointerEnter); | |
| canvas.addEventListener("pointerleave", onPointerLeave); | |
| canvas.addEventListener("mouseenter", onPointerEnter); | |
| canvas.addEventListener("mouseleave", onPointerLeave); | |
| canvas.addEventListener("mousedown", focusCanvas); | |
| canvas.addEventListener("touchstart", focusCanvas, { passive: true }); | |
| canvas.addEventListener("blur", onCanvasBlur); | |
| const onKeyDownCapture = (e) => { if (!isPointerOverCanvas) return; if (scrollKeys.has(e.key) || scrollKeys.has(e.code)) e.preventDefault(); }; | |
| window.addEventListener("keydown", onKeyDownCapture, true); | |
| progressDialog.style.display = "block"; | |
| // ---- Import PlayCanvas ---- | |
| if (!pc) { | |
| pc = await import("https://esm.run/playcanvas"); | |
| window.pc = pc; // debug | |
| } | |
| console.log("[VIEWER] PlayCanvas ESM chargé:", !!pc); | |
| // ---- Charge Ammo si activé ---- | |
| if (physicsEnabled) { | |
| const ammoOk = await loadAmmoWithFallback(pc, ammoBaseUrl, mobile ? 6000 : 4000); | |
| physicsEnabled = !!ammoOk; | |
| console.log("[VIEWER] Ammo status:", physicsEnabled ? "OK" : "DISABLED"); | |
| } | |
| // ---- Crée l’Application ---- | |
| const device = await pc.createGraphicsDevice(canvas, { | |
| deviceTypes: ["webgl2", "webgl1"], | |
| antialias: false | |
| }); | |
| device.maxPixelRatio = Math.min(window.devicePixelRatio || 1, maxDevicePixelRatio); | |
| const opts = new pc.AppOptions(); | |
| opts.graphicsDevice = device; | |
| opts.mouse = new pc.Mouse(canvas); | |
| opts.touch = new pc.TouchDevice(canvas); | |
| opts.keyboard= new pc.Keyboard(canvas); | |
| // IMPORTANT : inclure Collision et Rigidbody | |
| opts.componentSystems = [ | |
| pc.RenderComponentSystem, | |
| pc.CameraComponentSystem, | |
| pc.LightComponentSystem, | |
| pc.ScriptComponentSystem, | |
| pc.GSplatComponentSystem, | |
| pc.CollisionComponentSystem, | |
| pc.RigidbodyComponentSystem | |
| ]; | |
| opts.resourceHandlers = [pc.TextureHandler, pc.ContainerHandler, pc.ScriptHandler, pc.GSplatHandler]; | |
| app = new pc.Application(canvas, opts); | |
| app.setCanvasFillMode(pc.FILLMODE_NONE); | |
| app.setCanvasResolution(pc.RESOLUTION_AUTO); | |
| // Gravité selon le mode | |
| app.scene.gravity = freeFly ? new pc.Vec3(0, 0, 0) : new pc.Vec3(0, -9.81, 0); | |
| // Resize observé (debounce) | |
| resizeObserver = new ResizeObserver((entries) => { | |
| if (!entries || !entries.length) return; | |
| if (resizeTimeout) clearTimeout(resizeTimeout); | |
| resizeTimeout = setTimeout(() => { | |
| app.resizeCanvas(entries[0].contentRect.width, entries[0].contentRect.height); | |
| }, 60); | |
| }); | |
| resizeObserver.observe(viewerContainer); | |
| window.addEventListener("resize", () => { | |
| if (resizeTimeout) clearTimeout(resizeTimeout); | |
| resizeTimeout = setTimeout(() => { | |
| app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight); | |
| }, 60); | |
| }); | |
| app.on("destroy", () => { | |
| try { resizeObserver.disconnect(); } catch {} | |
| if (opts.keyboard && opts.keyboard.detach) opts.keyboard.detach(); | |
| window.removeEventListener("keydown", onKeyDownCapture, true); | |
| canvas.removeEventListener("pointerenter", onPointerEnter); | |
| canvas.removeEventListener("pointerleave", onPointerLeave); | |
| canvas.removeEventListener("mouseenter", onPointerEnter); | |
| canvas.removeEventListener("mouseleave", onPointerLeave); | |
| canvas.removeEventListener("mousedown", focusCanvas); | |
| canvas.removeEventListener("touchstart", focusCanvas); | |
| canvas.removeEventListener("blur", onCanvasBlur); | |
| }); | |
| // ---- Assets (SOG + GLB) ---- | |
| const assets = []; | |
| let sogAsset = null, glbAsset = null; | |
| if (sogUrl) { | |
| sogAsset = new pc.Asset("gsplat", "gsplat", { url: sogUrl }); | |
| app.assets.add(sogAsset); assets.push(sogAsset); | |
| } | |
| if (glbUrl) { | |
| glbAsset = new pc.Asset("glb", "container", { url: glbUrl }); | |
| app.assets.add(glbAsset); assets.push(glbAsset); | |
| } else { | |
| console.warn("[VIEWER] Aucun glb_url fourni — rien à afficher."); | |
| } | |
| // Script FPS (compat nom 'orbitCamera') | |
| ensureFirstPersonScriptRegistered(); | |
| // Charge les assets requis | |
| await new Promise((resolve, reject) => { | |
| const loader = new pc.AssetListLoader(assets, app.assets); | |
| loader.load(() => resolve()); | |
| loader.on('error', (e)=>{ console.error('[VIEWER] Asset load error:', e); reject(e); }); | |
| }); | |
| app.start(); | |
| progressDialog.style.display = "none"; | |
| console.log("[VIEWER] app.start OK — assets chargés"); | |
| // ---- GSplat (optionnel) ---- | |
| if (sogAsset) { | |
| modelEntity = new pc.Entity("model"); | |
| modelEntity.addComponent("gsplat", { asset: sogAsset }); | |
| app.root.addChild(modelEntity); | |
| } | |
| // ---- Environnement GLB ---- | |
| envEntity = glbAsset && glbAsset.resource ? glbAsset.resource.instantiateRenderEntity() : null; | |
| if (envEntity) { | |
| envEntity.name = "ENV_GLTF"; | |
| app.root.addChild(envEntity); | |
| // Mat “fond uni” si demandé | |
| if (!espace_expo_bool) { | |
| const matSol = new pc.StandardMaterial(); | |
| matSol.blendType = pc.BLEND_NONE; | |
| matSol.emissive = new pc.Color(color_bg); | |
| matSol.emissiveIntensity = 1; | |
| matSol.useLighting = false; | |
| matSol.update(); | |
| traverse(envEntity, (node) => { | |
| if (node.render && node.render.meshInstances) { | |
| for (const mi of node.render.meshInstances) mi.material = matSol; | |
| } | |
| }); | |
| } | |
| // ---- Colliders statiques “box” depuis AABB de chaque meshInstance ---- | |
| // Avantage : pas besoin d'asset collision, super robuste. | |
| if (physicsEnabled) { | |
| let rawCount = 0; | |
| let created = 0; | |
| traverse(envEntity, (node) => { | |
| if (node.render && node.render.meshInstances && node.render.meshInstances.length) { | |
| for (const mi of node.render.meshInstances) { | |
| rawCount++; | |
| const aabb = mi.aabb; // monde (pour render instancié, AABB est world-space) | |
| const center = aabb.center.clone(); | |
| const he = aabb.halfExtents.clone(); | |
| // crée un enfant collider “box” placé au centre de l’AABB | |
| const box = new pc.Entity(`COLL_BOX_${created}`); | |
| box.setPosition(center); | |
| box.addComponent('collision', { | |
| type: 'box', | |
| halfExtents: he | |
| }); | |
| box.addComponent('rigidbody', { | |
| type: 'static', | |
| friction: 0.6, | |
| restitution: 0.0 | |
| }); | |
| app.root.addChild(box); | |
| created++; | |
| } | |
| } | |
| }); | |
| console.log(`[VIEWER] Colliders statiques créés: ${created} (raw meshInstances=${rawCount})`); | |
| } else { | |
| console.warn("[VIEWER] Physics disabled: aucun collider statique créé."); | |
| } | |
| } else { | |
| console.warn("[VIEWER] GLB resource missing. Rien à collider."); | |
| } | |
| // ---- Caméra + Player capsule ---- | |
| cameraEntity = new pc.Entity("Camera"); | |
| cameraEntity.addComponent("camera", { | |
| clearColor: new pc.Color(color_bg), | |
| nearClip: 0.03, | |
| farClip: 500 | |
| }); | |
| if (physicsEnabled) { | |
| playerEntity = new pc.Entity("Player"); | |
| const capsuleRadius = config.capsuleRadius !== undefined ? parseFloat(config.capsuleRadius) : 0.35; | |
| const capsuleHeight = config.capsuleHeight !== undefined ? parseFloat(config.capsuleHeight) : 1.70; | |
| playerEntity.addComponent('collision', { | |
| type: 'capsule', | |
| radius: capsuleRadius, | |
| height: capsuleHeight | |
| }); | |
| playerEntity.addComponent('rigidbody', { | |
| type: 'dynamic', | |
| mass: 75, | |
| friction: 0.3, | |
| restitution: 0.0, | |
| linearDamping: 0.15, | |
| angularDamping: 0.999 | |
| }); | |
| // Bloque la rotation pour éviter le roulis | |
| playerEntity.rigidbody.angularFactor = new pc.Vec3(0, 0, 0); | |
| // Spawn | |
| playerEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ); | |
| app.root.addChild(playerEntity); | |
| // Caméra à hauteur des yeux | |
| const eyes = config.eyesOffsetY !== undefined | |
| ? parseFloat(config.eyesOffsetY) | |
| : Math.max(0.4, capsuleHeight * 0.9 - capsuleRadius); // ~ yeux | |
| cameraEntity.setLocalPosition(0, eyes, 0); | |
| playerEntity.addChild(cameraEntity); | |
| // Script FPS | |
| playerEntity.addComponent("script"); | |
| playerEntity.script.create("orbitCamera", { | |
| attributes: { | |
| cameraEntity: cameraEntity, | |
| moveSpeed: (config.moveSpeed !== undefined ? parseFloat(config.moveSpeed) : 4.0), | |
| lookSpeed: (config.lookSpeed !== undefined ? parseFloat(config.lookSpeed) : 0.25), | |
| pitchAngleMin: (config.minAngle !== undefined ? parseFloat(config.minAngle) : -89), | |
| pitchAngleMax: (config.maxAngle !== undefined ? parseFloat(config.maxAngle) : 89), | |
| freeFly: !!freeFly | |
| } | |
| }); | |
| } else { | |
| // Pas de physique : caméra seule (pas recommandé si tu veux collisions) | |
| cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ); | |
| app.root.addChild(cameraEntity); | |
| } | |
| // Regarder vers le modèle si présent, sinon vers l’origine | |
| const lookTarget = modelEntity ? modelEntity.getPosition() : new pc.Vec3(0, 1, 0); | |
| cameraEntity.lookAt(lookTarget); | |
| // Taille initiale | |
| app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight); | |
| // DPR dynamique : réduit pendant interaction | |
| const setDpr = (val) => { | |
| const clamped = Math.max(0.5, Math.min(val, maxDevicePixelRatio)); | |
| if (app.graphicsDevice.maxPixelRatio !== clamped) { | |
| app.graphicsDevice.maxPixelRatio = clamped; | |
| app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight); | |
| } | |
| }; | |
| const bumpInteraction = () => { | |
| setDpr(interactDpr); | |
| if (idleTimer) clearTimeout(idleTimer); | |
| idleTimer = setTimeout(() => { | |
| setDpr(Math.min(window.devicePixelRatio || 1, maxDevicePixelRatio)); | |
| }, idleRestoreDelay); | |
| }; | |
| ["mousedown", "mousemove", "mouseup", "wheel", "touchstart", "touchmove", "keydown"] | |
| .forEach((ev) => canvas.addEventListener(ev, bumpInteraction, { passive: true })); | |
| viewerInitialized = true; | |
| console.log("[VIEWER] READY — physics=", physicsEnabled ? "ON" : "OFF", "env=", !!envEntity, "sog=", !!modelEntity); | |
| } | |
| /* ------------------------------------------- | |
| API helper : repositionner le joueur/caméra | |
| -------------------------------------------- */ | |
| export function resetViewerCamera(x, y, z) { | |
| try { | |
| if (!app) return; | |
| const nx = (x !== undefined) ? parseFloat(x) : null; | |
| const ny = (y !== undefined) ? parseFloat(y) : null; | |
| const nz = (z !== undefined) ? parseFloat(z) : null; | |
| if (playerEntity && playerEntity.rigidbody) { | |
| const p = playerEntity.getPosition().clone(); | |
| playerEntity.rigidbody.teleport(nx ?? p.x, ny ?? p.y, nz ?? p.z, playerEntity.getRotation()); | |
| playerEntity.rigidbody.linearVelocity = new pc.Vec3(0, 0, 0); | |
| playerEntity.rigidbody.angularVelocity = new pc.Vec3(0, 0, 0); | |
| } else if (cameraEntity) { | |
| const p = cameraEntity.getPosition().clone(); | |
| cameraEntity.setPosition(nx ?? p.x, ny ?? p.y, nz ?? p.z); | |
| } | |
| } catch (e) { | |
| console.error("[viewer_pr_env] resetViewerCamera error:", e); | |
| } | |
| } | |