MikaFil's picture
Update deplacement_dans_env/viewer_pr_env.js
27c4a59 verified
// 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);
}
}