Files
gta6/three-player-controller/example/glTF.js
T
knsoandClaude Opus 5 fb38652e7f chore(controller): three-player-controller als Ordner statt Submodul
Das Submodul zeigte auf GitHub, der eingetragene Stand 028c652 lag aber nur
lokal vor: GitHub-Master ac39682 plus Showcase mit Figuren und launch.json.
Frische Klone konnten den Controller deshalb nicht laden. Jetzt liegt dieser
Stand als normaler Ordner im Repo, ohne Verweis auf GitHub.

dist/ und node_modules/ im Controller bleiben ignoriert und werden wie bisher
lokal gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 21:27:03 +02:00

1129 lines
42 KiB
JavaScript

import {
ACESFilmicToneMapping,
AmbientLight,
CircleGeometry,
DoubleSide,
Float32BufferAttribute,
Timer,
EquirectangularReflectionMapping,
Mesh,
MeshStandardMaterial,
PerspectiveCamera,
Raycaster,
Scene,
SphereGeometry,
Vector2,
Vector3,
WebGLRenderer,
} from "three";
import { MapControls } from "three/examples/jsm/Addons.js";
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { HDRLoader } from "three/examples/jsm/loaders/HDRLoader.js";
import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js";
import { playerController } from "../src/PlayerController";
import { FootIK } from "../src/foot-ik";
import { bindVehicleHintMode } from "./control-hints.js";
import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js';
import Stats from 'three/examples/jsm/libs/stats.module.js';
import { CSM } from "three/examples/jsm/csm/CSM.js";
import { createVolumeCloud, disposeVolumeCloud, updateVolumeCloud } from "./volumeCloud.js";
let player;
const scene = new Scene();
let camera;
let renderer;
let controls;
let gltfLoader;
let guiParams = null;
let footIK = null;
let footIKDebugParams = null;
let gui = null;
let stats = null;
let csm = null;
let currentBlobUrl = null;
let platform = null;
let platformCloud = null;
let dynamicPlatforms = [];
const dynamicPlatformXPath = [
new Vector3(20.94, 3.70, 14.89),
new Vector3(-1.32, 7.65, 14.83),
new Vector3(-19.85, 14.38, 8.77),
];
const dynamicPlatformXSegments = dynamicPlatformXPath.slice(0, -1).map((point, index) => ({
from: point,
to: dynamicPlatformXPath[index + 1],
length: point.distanceTo(dynamicPlatformXPath[index + 1]),
}));
const dynamicPlatformXLength = dynamicPlatformXSegments.reduce((sum, segment) => sum + segment.length, 0);
const animTimer = new Timer();
let globalScale = 1;
let previewMesh = null;
let previewMode = false;
let previewHintEl = null;
// CSM 基准参数
const CSM_BASE = { maxFar: 30, lightNear: 0.1, lightFar: 50, lightMargin: 30, lightIntensity: 10 };
const previewRaycaster = new Raycaster();
const previewMouse = new Vector2();
const modelUrl = "./glb/crash_junction.glb";
const pos = new Vector3(21.5, 4, 15);
// 人物模型配置
function easeEndsLinearMiddle(progress, easeRatio = 0.18) {
const ease = Math.min(Math.max(easeRatio, 0.001), 0.49);
const maxSpeed = 1 / (1 - ease);
if (progress < ease) return (maxSpeed * progress * progress) / (2 * ease);
if (progress > 1 - ease) return 1 - (maxSpeed * (1 - progress) * (1 - progress)) / (2 * ease);
return maxSpeed * (progress - ease / 2);
}
// 根据整条路径的总长度定位平台
function setPositionOnXPath(mesh, progress) {
if (!dynamicPlatformXSegments.length || dynamicPlatformXLength <= 0) return;
let targetDistance = progress * dynamicPlatformXLength;
for (const segment of dynamicPlatformXSegments) {
if (targetDistance <= segment.length) {
mesh.position.lerpVectors(segment.from, segment.to, targetDistance / segment.length);
return;
}
targetDistance -= segment.length;
}
mesh.position.copy(dynamicPlatformXPath[dynamicPlatformXPath.length - 1]);
}
const PLAYER_MODELS = {
josh: {
url: "./glb/josh.glb",
scale: 0.001,
idleAnim: "idle1",
walkAnim: "walk",
runAnim: "run",
jumpAnim: ["jump", "Jump_Loop", "Jump_Land"],
flyAnim: "fly",
flyIdleAnim: "flyIdle",
flyHoverForwardAnim: "flyHoverForward",
flyHoverBackAnim: "flyHoverBack",
flyHoverLeftAnim: "flyHoverLeft",
flyHoverRightAnim: "flyHoverRight",
flyHoverUpAnim: "flyHoverUp",
flyHoverDownAnim: "flyHoverDown",
drivingAnim: "Driving_Loop",
headBoneName: "mixamorigHead",
rotateY: Math.PI,
},
maw: {
url: "./glb/maw.glb",
scale: 0.005,
idleAnim: "idle",
walkAnim: "walk",
runAnim: "run",
jumpAnim: "jump",
flyAnim: "flying",
flyIdleAnim: "flyidle",
headBoneName: "mixamorigHead",
rotateY: Math.PI,
},
ual: {
url: "./glb/ual.glb",
scale: 0.001,
idleAnim: "Idle_Loop",
walkAnim: "Walk_Loop",
runAnim: "Sprint_Loop",
jumpAnim: ["Jump_Start", "Jump_Loop", "Jump_Land"],
flyAnim: "fly",
flyIdleAnim: "flyIdle",
flyHoverForwardAnim: "flyHoverForward",
flyHoverBackAnim: "flyHoverBack",
flyHoverLeftAnim: "flyHoverLeft",
flyHoverRightAnim: "flyHoverRight",
flyHoverUpAnim: "flyHoverUp",
flyHoverDownAnim: "flyHoverDown",
drivingAnim: "Driving_Loop",
headBoneName: "Head",
rotateY: Math.PI,
firstPersonCameraOffset: [0, 0.15, 0.12],
},
};
// 车辆配置
const VEHICLE_TUNING = {
followVehicleDirection: true,
debug: { showPhysicsBox: false, showWheelRays: false, showWheelTravel: false, showWheelSpheres: false },
chassis: {
density: 1,
linearDamping: 0.05,
angularDamping: 0.5,
clearance: 0.25,
sizeScale: { x: 0.9, y: 0.65, z: 0.9 },
},
suspension: {
restLength: 0.15,
maxTravel: 0.15,
stiffness: 25,
compression: 2.1,
relaxation: 2.5,
maxForce: 6000,
frictionSlip: 8,
sideFrictionStiffness: 1,
rollInfluence: 0.12,
},
steering: {
maxSteerAngle: Math.PI / 5,
steerTime: 0.45,
steerReturnTimeSlow: 0.55,
steerReturnTimeFast: 0.4,
highSpeedSteerScale: 0.3,
},
grip: {
maxG: 1.2,
sideFrictionIdle: 1,
sideFrictionFrontMin: 0.55,
sideFrictionRearMin: 0.45,
handbrakeRearFriction: 0.35,
handbrakeRearDriveScale: 0.65,
handbrakeReleaseTime: 0.15,
wheelbaseRatio: 0.55,
},
power: {
maxSpeed: 100,
acceleration: 5,
deceleration: 5,
},
};
const VEHICLE_CONFIG = {
url: "./glb/sedan.glb",
scale: 0.1,
wheelsNames: ["Wheel_LF", "Wheel_RF", "Wheel_LR", "Wheel_RR"],
followVehicleDirection: VEHICLE_TUNING.followVehicleDirection,
debug: VEHICLE_TUNING.debug,
chassis: VEHICLE_TUNING.chassis,
suspension: VEHICLE_TUNING.suspension,
steering: VEHICLE_TUNING.steering,
grip: VEHICLE_TUNING.grip,
power: VEHICLE_TUNING.power,
driverSeatPosition: new Vector3(-0.6, 1.0, 0.4),
driverSeatRotation: -Math.PI / 2,
};
init();
function setupCSMMaterial(material) {
if (!material || !csm) return;
const mats = Array.isArray(material) ? material : [material];
mats.forEach((m) => {
csm.setupMaterial(m);
// 材质可能已在异步加载期间编译,注入 CSM 后必须刷新 shader。
m.needsUpdate = true;
});
}
function createCSM(shadowMapSize, scale) {
const c = new CSM({
maxFar: CSM_BASE.maxFar * scale,
cascades: 3,
mode: "practical",
parent: scene,
shadowMapSize,
shadowBias: -0.00001,
lightDirection: new Vector3(-1, -2, -1).normalize(),
lightIntensity: CSM_BASE.lightIntensity,
lightNear: CSM_BASE.lightNear * scale,
lightFar: CSM_BASE.lightFar * scale,
camera,
fade: true,
lightMargin: CSM_BASE.lightMargin * scale,
});
c.lights.forEach((light, index) => {
const biasMult = Math.pow(2, index);
light.shadow.bias = -0.0001 * biasMult;
light.shadow.normalBias = 0.002 * biasMult;
});
return c;
}
function recreateCSM(scale) {
csm.remove();
csm.dispose();
const maxTextureSize = renderer.capabilities.maxTextureSize;
csm = createCSM(Math.min(2048, maxTextureSize), scale);
// 重新绑定场景内所有网格的材质
scene.traverse((child) => {
if (child.isMesh) setupCSMMaterial(child.material);
});
}
function getModelScale(key) {
return PLAYER_MODELS[key].scale * globalScale;
}
async function loadPlayerModelConfig(key) {
const m = PLAYER_MODELS[key];
const gltf = await gltfLoader.loadAsync(m.url);
const { url, ...rest } = m;
return {
...rest,
model: gltf.scene,
animations: gltf.animations,
scale: rest.scale * globalScale,
};
}
async function loadVehicleConfig(extra = {}) {
const gltf = await gltfLoader.loadAsync(VEHICLE_CONFIG.url);
const { url, ...rest } = VEHICLE_CONFIG;
return {
...rest,
model: gltf.scene,
...extra,
};
}
// 加载车辆。
async function spawnSceneSedan() {
await player.loadVehicleModel(await loadVehicleConfig({
scale: VEHICLE_CONFIG.scale * globalScale,
position: pos.clone().add(new Vector3(0, -0.3, -0.5)),
}));
const sedan = player.getAllVehicles().at(-1);
sedan?.vehicleGroup?.traverse((child) => {
if (!child.isMesh) return;
child.castShadow = true;
child.receiveShadow = true;
setupCSMMaterial(child.material);
const materials = Array.isArray(child.material) ? child.material : [child.material];
materials.forEach((material) => {
if (!material) return;
material.metalness = 0.8;
material.roughness = 0;
});
});
applyVehicleTuning();
}
// Foot IK 使用通用骨骼名识别,模型切换后由插件自动重新绑定。
function attachFootIK() {
const p = guiParams;
footIK = new FootIK({
enabled: p?.footIKEnabled ?? true,
debug: (p?.footIKEnabled ?? true) && (p?.footIKDebug ?? false),
maxPelvisRaise: p?.maxPelvisRaise ?? 36,
maxPelvisDrop: p?.maxPelvisDrop ?? 36,
maxFootRaise: p?.maxFootRaise ?? 60,
maxFootDrop: p?.maxFootDrop ?? 36,
soleHalfWidth: p?.soleHalfWidth ?? 7,
soleToeExtend: p?.soleToeExtend ?? 7,
soleHeelExtend: p?.soleHeelExtend ?? 3,
soleSkinThickness: p?.soleSkinThickness ?? 1.6,
straightPoleEnabled: p?.straightPoleEnabled ?? true,
});
player.use(footIK);
if (p) footIKDebugParams = p;
}
/** 动态球视觉 */
function createDemoSphereMesh(color = 0x88c0d0) {
const geo = new SphereGeometry(1, 24, 16);
const pos = geo.attributes.position;
const colors = new Float32Array(pos.count * 3);
const r = ((color >> 16) & 255) / 255;
const g = ((color >> 8) & 255) / 255;
const b = (color & 255) / 255;
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i);
const y = pos.getY(i);
const band = Math.sin(x * 8 + y * 2) > 0;
const dark = band ? 0.35 : 1;
colors[i * 3] = r * dark;
colors[i * 3 + 1] = g * dark;
colors[i * 3 + 2] = b * dark;
}
geo.setAttribute("color", new Float32BufferAttribute(colors, 3));
const mesh = new Mesh(
geo,
new MeshStandardMaterial({ vertexColors: true, roughness: 0.55, metalness: 0.05 }),
);
mesh.castShadow = true;
mesh.receiveShadow = true;
return mesh;
}
// 创建动态平台
function createDynamicPlatform({
position,
radius = 0.16,
cloudScale = [0.32, 0.15, 0.32],
motion = null,
}) {
// 网格
const mesh = new Mesh(
new CircleGeometry(radius, 32),
new MeshStandardMaterial({ color: 0x88ccff, transparent: true, opacity: 0.7, metalness: 0, roughness: 0.5, side: DoubleSide }),
);
mesh.position.copy(position);
mesh.rotation.x = -Math.PI / 2;
mesh.material.visible = false;
scene.add(mesh);
player.addCollider({
motion: "kinematic",
shape: { kind: "mesh", source: mesh },
follow: mesh,
});
// 体积云
const cloud = createVolumeCloud({
scale: cloudScale,
opacity: 0.28,
steps: 80,
});
cloud.position.set(0, 0, 0);
cloud.rotation.x = Math.PI / 2;
mesh.add(cloud);
// 缓存数据
const entry = {
mesh,
cloud,
basePosition: position.clone(),
motion,
};
dynamicPlatforms.push(entry);
return entry;
}
// 更新动态平台
function updateDynamicPlatforms() {
const t = animTimer.getElapsed();
dynamicPlatforms.forEach((entry) => {
const { mesh, basePosition, motion, cloud } = entry;
if (motion) {
// 还原基点
if (motion.axis === "y") {
mesh.position.copy(basePosition);
const amount = Math.sin(t * motion.speed) * motion.distance;
// 纵向往返
mesh.position.y = basePosition.y + amount + motion.distance;
} else if (motion.axis === "x") {
if (player.getActiveKinematicCollider()?.source === mesh && player.getIsOnGround()) {
entry.motionElapsed = (entry.motionElapsed ?? 0) + player.getCurrentDelta();
const phase = (entry.motionElapsed * motion.speed / Math.PI) % 2;
const rawProgress = phase <= 1 ? phase : 2 - phase;
const progress = easeEndsLinearMiddle(rawProgress);
setPositionOnXPath(mesh, progress);
}
}
}
// 更新体积云
updateVolumeCloud(cloud, camera);
});
}
// 移除平台
function removeDynamicPlatforms() {
dynamicPlatforms.forEach(({ mesh, cloud }) => {
// 清理碰撞
player?.removeKinematicCollider(mesh);
disposeVolumeCloud(cloud);
scene.remove(mesh);
});
dynamicPlatforms = [];
platform = null;
platformCloud = null;
}
async function init() {
const cont = document.querySelector("#container");
// 渲染器
renderer = new WebGLRenderer({ antialias: true });
renderer.setSize(cont.clientWidth, cont.clientHeight);
renderer.shadowMap.enabled = false;
renderer.toneMapping = ACESFilmicToneMapping;
renderer.toneMappingExposure = 1;
renderer.setAnimationLoop(animate);
cont.appendChild(renderer.domElement);
// 相机
camera = new PerspectiveCamera(60, cont.clientWidth / cont.clientHeight, 0.01, 1000);
camera.position.copy(pos);
camera.lookAt(pos.x, pos.y, pos.z + 1);
// 控制器
controls = new MapControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.maxDistance = 2000;
controls.dampingFactor = 0.1;
controls.rotateSpeed = 1;
controls.maxPolarAngle = Math.PI / 2;
controls.target.set(pos.x, pos.y, pos.z + 1);
const maxTextureSize = renderer.capabilities.maxTextureSize;
const shadowMapSize = Math.min(2048, maxTextureSize);
// 级联阴影
csm = createCSM(shadowMapSize, 1);
csm.lights.forEach((light, index) => {
const biasMult = Math.pow(2, index);
light.shadow.bias = -0.0001 * biasMult;
light.shadow.normalBias = 0.002 * biasMult;
});
// 环境光
const ambient = new AmbientLight(0xffffff, 5);
scene.add(ambient);
// 背景
new HDRLoader().load(
"./img/env.hdr",
(texture) => {
texture.mapping = EquirectangularReflectionMapping;
scene.background = texture;
},
undefined,
(err) => console.warn("HDR 加载失败:", err)
);
// 帧率显示
stats = new Stats();
Object.assign(stats.dom.style, {
position: "fixed",
bottom: "0",
left: "0",
top: "auto",
zIndex: "9998",
});
document.body.appendChild(stats.dom);
// 加载场景
initGltfLoader();
const sceneModel = await initGLBScene(modelUrl);
renderer.render(scene, camera);
// 人物控制器
player = new playerController();
await player.init({
scene,
camera,
controls,
playerModelConfig: {
...(await loadPlayerModelConfig("josh")),
speed: 150,
runSpeed: 600,
},
initPos: pos,
minCamDistance: 8,
maxCamDistance: 300,
camLookAtHeightRatio: 0.5,
enableSpringCamera: true,
springCameraTime: 0.1,
enableOverShoulderView: true,
camOverShoulderOffsetRatio: 0,
enableZoom: true,
colliders: [
{ motion: "static", shape: { kind: "mesh", source: sceneModel } },
],
});
bindVehicleHintMode(player);
attachFootIK();
// 先完成角色材质的 CSM 注入,避免等待车辆时用非 CSM shader 提前编译。
player.getPlayerModel()?.traverse((child) => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
setupCSMMaterial(child.material);
}
});
await spawnSceneSedan();
// 创建动态平台
const liftPlatform = createDynamicPlatform({
position: new Vector3(22, 2.76, 9.7),
motion: { axis: "y", distance: 4, speed: 0.5 },
});
platform = liftPlatform.mesh;
platformCloud = liftPlatform.cloud;
createDynamicPlatform({
position: dynamicPlatformXPath[0],
motion: { axis: "x", distance: 3, speed: 0.1 },
});
// 调试
initGUI();
window.addEventListener("keydown", (e) => {
if (e.code !== "KeyR" || e.repeat) return;
player.resetVehicle();
});
window.addEventListener("resize", onWindowResize, false);
// 关闭加载页面
window.hideLoader();
}
// 初始化glb加载器
function initGltfLoader() {
gltfLoader = new GLTFLoader();
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath("https://unpkg.com/three@0.180.0/examples/jsm/libs/draco/");
gltfLoader.setDRACOLoader(dracoLoader);
const ktx2Loader = new KTX2Loader();
ktx2Loader.setTranscoderPath("https://unpkg.com/three@0.180.0/examples/jsm/libs/basis/");
ktx2Loader.detectSupport(renderer);
gltfLoader.setKTX2Loader(ktx2Loader);
}
// 加载场景
async function initGLBScene(url, modelScale = [10, 10, 10]) {
try {
const gltf = await gltfLoader.loadAsync(url);
const model = gltf.scene;
model.name = "sceneGLB";
model.scale.set(...modelScale);
model.traverse((child) => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
setupCSMMaterial(child.material);
}
});
scene.add(model);
return model;
} catch (e) {
console.error("GLB 加载失败:", e);
return null;
}
}
// 更换场景
async function replaceScene(file) {
const blobUrl = URL.createObjectURL(file);
// 退出指针锁定
if (document.pointerLockElement) {
await new Promise((resolve) => {
document.addEventListener("pointerlockchange", resolve, { once: true });
document.exitPointerLock();
});
}
// 移除旧场景并释放 GPU 资源
const old = scene.getObjectByName("sceneGLB");
if (old) {
old.traverse((child) => {
if (child.isMesh) {
child.geometry?.dispose();
const mats = Array.isArray(child.material) ? child.material : [child.material];
mats.forEach((m) => m?.dispose());
}
});
scene.remove(old);
}
// 销毁旧玩家
removeDynamicPlatforms();
player?.destroy();
player = null;
footIK = null;
footIKDebugParams = null;
// 加载新场景
await initGLBScene(blobUrl, [1, 1, 1]);
// 释放上一个 blob URL,保存新的
if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl);
currentBlobUrl = blobUrl;
// 进入预览模式
await enterPreviewMode(guiParams?.playerModel ?? "josh");
}
// 进入预览模式
async function enterPreviewMode(playerModelKey) {
previewMode = true;
const modelConfig = PLAYER_MODELS[playerModelKey];
const gltf = await gltfLoader.loadAsync(modelConfig.url);
previewMesh = gltf.scene;
previewMesh.scale.setScalar(modelConfig.scale * globalScale);
previewMesh.visible = false;
previewMesh.traverse((child) => {
if (child.isMesh) {
const mats = Array.isArray(child.material) ? child.material : [child.material];
mats.forEach((m) => {
m.transparent = true;
m.opacity = 0.5;
m.depthWrite = false;
});
}
});
scene.add(previewMesh);
if (!previewHintEl) {
previewHintEl = document.createElement("div");
Object.assign(previewHintEl.style, {
position: "fixed", bottom: "20px", left: "50%",
transform: "translateX(-50%)", background: "rgba(0,0,0,0.65)",
color: "#fff", padding: "12px 24px", borderRadius: "8px",
fontSize: "14px", zIndex: "9999", textAlign: "center",
display: "flex", flexDirection: "column", alignItems: "center", gap: "8px",
});
const tip = document.createElement("span");
tip.textContent = "移动鼠标预览人物位置 · 双击确认放置";
const sliderRow = document.createElement("div");
Object.assign(sliderRow.style, { display: "flex", alignItems: "center", gap: "8px" });
const label = document.createElement("span");
label.textContent = "人物比例:";
// 对数刻度:slider在log10空间线性滑动,0.01~1000 各数量级间距相同
const slider = document.createElement("input");
slider.type = "range";
slider.min = "-2"; // log10(0.01)
slider.max = "3"; // log10(1000)
slider.step = "0.01";
slider.value = String(Math.log10(globalScale));
slider.style.width = "160px";
const sliderVal = document.createElement("span");
sliderVal.textContent = globalScale.toFixed(2);
slider.addEventListener("input", () => {
globalScale = Math.pow(10, parseFloat(slider.value));
sliderVal.textContent = globalScale.toFixed(2);
if (previewMesh) {
previewMesh.scale.setScalar(modelConfig.scale * globalScale);
}
});
sliderRow.append(label, slider, sliderVal);
previewHintEl.append(tip, sliderRow);
document.body.appendChild(previewHintEl);
} else {
// 重置滑块值
const slider = previewHintEl.querySelector("input[type=range]");
const sliderVal = previewHintEl.querySelector("span:last-child");
if (slider) { slider.value = String(Math.log10(globalScale)); }
if (sliderVal) { sliderVal.textContent = globalScale.toFixed(2); }
}
previewHintEl.style.display = "flex";
renderer.domElement.addEventListener("mousemove", onPreviewMouseMove);
renderer.domElement.addEventListener("dblclick", onPreviewDblClick);
}
// 退出预览模式
function exitPreviewMode() {
previewMode = false;
controls.enableZoom = true;
if (previewMesh) {
previewMesh.traverse((child) => {
if (child.isMesh) {
child.geometry?.dispose();
const mats = Array.isArray(child.material) ? child.material : [child.material];
mats.forEach((m) => m?.dispose());
}
});
scene.remove(previewMesh);
previewMesh = null;
}
if (previewHintEl) previewHintEl.style.display = "none";
renderer.domElement.removeEventListener("mousemove", onPreviewMouseMove);
renderer.domElement.removeEventListener("dblclick", onPreviewDblClick);
}
// 预览:鼠标移动 → 射线交点跟随
function onPreviewMouseMove(e) {
if (!previewMode || !previewMesh) return;
previewMouse.set(
(e.clientX / window.innerWidth) * 2 - 1,
-(e.clientY / window.innerHeight) * 2 + 1,
);
previewRaycaster.setFromCamera(previewMouse, camera);
const sceneModel = scene.getObjectByName("sceneGLB");
if (!sceneModel) return;
const hits = previewRaycaster.intersectObject(sceneModel, true);
if (hits.length > 0) {
previewMesh.position.copy(hits[0].point);
previewMesh.visible = true;
} else {
previewMesh.visible = false;
}
}
// 预览:双击 → 确认位置,初始化玩家
async function onPreviewDblClick() {
if (!previewMode || !previewMesh?.visible) return;
const initPos = previewMesh.position.clone();
const spawnKey = guiParams?.playerModel ?? "josh";
const spawnScale = getModelScale(spawnKey);
initPos.y += 180 * spawnScale * 0.75;
exitPreviewMode();
recreateCSM(globalScale);
player = new playerController();
const sceneModel = scene.getObjectByName("sceneGLB");
await player.init({
scene,
camera,
controls,
playerModelConfig: {
...(await loadPlayerModelConfig(spawnKey)),
speed: guiParams?.playerSpeed ?? 150,
runSpeed: guiParams?.playerRunSpeed ?? 600,
},
initPos,
minCamDistance: guiParams?.minCamDistance ?? 8,
maxCamDistance: guiParams?.maxCamDistance ?? 300,
camLookAtHeightRatio: guiParams?.camLookAtHeightRatio ?? 0.5,
enableSpringCamera: guiParams?.enableSpringCamera ?? true,
springCameraTime: guiParams?.springCameraTime ?? 0.1,
enableOverShoulderView: guiParams?.enableOverShoulderView ?? true,
camOverShoulderOffsetRatio: guiParams?.camOverShoulderOffsetRatio ?? 0,
thirdMouseMode: guiParams?.thirdMouseMode ?? 1,
enableZoom: guiParams?.enableZoom ?? true,
colliders: [
{ motion: "static", shape: { kind: "mesh", source: sceneModel } },
],
});
bindVehicleHintMode(player);
attachFootIK();
player.getPlayerModel()?.traverse((child) => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
setupCSMMaterial(child.material);
}
});
}
// 每帧调用
function animate(timestamp) {
animTimer.update(timestamp);
if (player) {
player.update();
} else {
controls.update();
}
updateDynamicPlatforms();
csm?.update();
updateFootIKDebugPanel();
renderer.render(scene, camera);
stats?.update();
}
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
renderer.setSize(window.innerWidth, window.innerHeight);
camera.updateProjectionMatrix();
renderer.setPixelRatio(window.devicePixelRatio * 1);
}
// 调试
function applyVehicleTuning() {
const t = VEHICLE_TUNING;
const vehicles = player?.getAllVehicles?.() ?? [];
for (const v of vehicles) {
player?.setVehicleChassisSizeScale?.(v, t.chassis.sizeScale);
player?.setVehicleClearance?.(v, t.chassis.clearance);
const he = v.halfExtents;
const volume = 8 * he.x * he.y * he.z;
v.chassisBody.setMass(volume * Math.max(1e-8, t.chassis.density));
v.chassisBody.linearDamping = t.chassis.linearDamping;
v.chassisBody.angularDamping = t.chassis.angularDamping;
Object.assign(v.steering, t.steering);
Object.assign(v.grip, t.grip);
v.maxSpeed = t.power.maxSpeed * v.scale;
v.acceleration = t.power.acceleration * v.scale;
v.deceleration = t.power.deceleration * v.scale;
v.followVehicleDirection = t.followVehicleDirection;
const sus = t.suspension;
const staticSag = 9.81 * v.scale / (4 * Math.max(1e-4, sus.stiffness));
const rest = Math.max(sus.restLength * v.scale, staticSag * 1.2);
v.sideFrictionStiffness = sus.sideFrictionStiffness;
const wheelCount = v.vehicleController.numWheels();
for (let i = 0; i < wheelCount; i++) {
v.vehicleController.setWheelSuspensionRestLength(i, rest);
v.vehicleController.setWheelMaxSuspensionTravel(i, sus.maxTravel * v.scale);
v.vehicleController.setWheelSuspensionStiffness(i, sus.stiffness);
v.vehicleController.setWheelSuspensionCompression(i, sus.compression);
v.vehicleController.setWheelSuspensionRelaxation(i, sus.relaxation);
v.vehicleController.setWheelMaxSuspensionForce(i, sus.maxForce);
v.vehicleController.setWheelFrictionSlip(i, sus.frictionSlip);
v.vehicleController.setWheelSideFrictionStiffness(i, sus.sideFrictionStiffness);
v.vehicleController.setWheelRollInfluence(i, sus.rollInfluence);
}
if (v.physicsBoxMesh) {
if (t.debug.showPhysicsBox) v.vehicleGroup.add(v.physicsBoxMesh);
else v.vehicleGroup.remove(v.physicsBoxMesh);
}
if (v.wheelRayDebug) v.wheelRayDebug.visible = t.debug.showWheelRays;
if (v.wheelTravelDebug) v.wheelTravelDebug.visible = t.debug.showWheelTravel;
if (v.wheelSphereDebug) v.wheelSphereDebug.visible = t.debug.showWheelSpheres;
}
if (player?.vehicle?.params?.debug) {
Object.assign(player.vehicle.params.debug, t.debug);
}
}
function initGUI() {
const options = footIK.getOptions();
const playerScale = player.playerModelConfig.scale;
const toBasePlayerValue = (value) => value / playerScale;
const params = {
playerModel: "josh",
showShadow: renderer.shadowMap.enabled,
colliderDebug: false,
playerCapsuleDebug: false,
mouseSensitivity: player.cam.sensitivity,
gravity: toBasePlayerValue(player.gravity),
jumpHeight: toBasePlayerValue(player.jumpHeight),
playerSpeed: toBasePlayerValue(player.playerSpeed),
playerRunSpeed: toBasePlayerValue(player.playerRunSpeed),
flySpeed: toBasePlayerValue(player.playerFlySpeed),
playerAcceleration: player.playerAcceleration,
playerDeceleration: player.playerDeceleration,
timeScale: player.timeScale,
minCamDistance: toBasePlayerValue(player.cam.minDist),
maxCamDistance: toBasePlayerValue(player.cam.maxDist),
camLookAtHeightRatio: player.cam.lookAtHeightRatio,
enableOverShoulderView: player.enableOverShoulderView,
camOverShoulderOffsetRatio: player.cam.overShoulderOffsetRatio,
enableSpringCamera: player.cam.enableSpringCamera,
springCameraTime: player.cam.springCameraTime,
thirdMouseMode: player.cam.mouseMode,
enableZoom: player.cam.zoomEnabled,
footIKEnabled: options.enabled,
footIKDebug: options.debug,
straightPoleEnabled: options.straightPoleEnabled,
leftFootPhase: "",
leftFootLandIK: "-- / 0.00",
rightFootPhase: "",
rightFootLandIK: "-- / 0.00",
maxPelvisRaise: options.maxPelvisRaise,
maxPelvisDrop: options.maxPelvisDrop,
maxFootRaise: options.maxFootRaise,
maxFootDrop: options.maxFootDrop,
soleHalfWidth: options.soleHalfWidth,
soleToeExtend: options.soleToeExtend,
soleHeelExtend: options.soleHeelExtend,
soleSkinThickness: options.soleSkinThickness,
maxSteerDeg: VEHICLE_TUNING.steering.maxSteerAngle * 180 / Math.PI,
};
guiParams = params;
footIKDebugParams = params;
const applyFootIKOptions = (options) => footIK?.configure(options);
gui = new GUI({ title: "Showcase Controls", width: 320 });
if (window.matchMedia("(hover: none) and (pointer: coarse), (max-width: 768px)").matches) gui.close();
Object.assign(gui.domElement.style, {
position: "fixed",
top: "12px",
right: "12px",
maxHeight: "calc(100vh - 24px)",
overflowY: "auto",
});
gui.domElement.addEventListener("pointerdown", (event) => event.stopPropagation());
const sceneFolder = gui.addFolder("Scene");
sceneFolder.add(params, "playerModel", Object.keys(PLAYER_MODELS))
.name("Character Model")
.onChange(async (modelKey) => {
await player?.switchPlayerModel(await loadPlayerModelConfig(modelKey));
player?.getPlayerModel()?.traverse((child) => {
if (!child.isMesh) return;
child.castShadow = true;
child.receiveShadow = true;
setupCSMMaterial(child.material);
if (modelKey !== "ual") return;
const materials = Array.isArray(child.material) ? child.material : [child.material];
materials.forEach((material) => {
if (!material) return;
material.metalness = 0.8;
material.roughness = 0;
});
});
});
sceneFolder.add(params, "showShadow").name("Shadows").onChange((value) => {
renderer.shadowMap.enabled = value;
scene.traverse((child) => {
if (!child.isMesh) return;
const materials = Array.isArray(child.material) ? child.material : [child.material];
materials.forEach((material) => {
if (material) material.needsUpdate = true;
});
});
});
sceneFolder.open();
const collisionFolder = gui.addFolder("Collision Debug");
collisionFolder.add(params, "colliderDebug").name("Static / Kinematic Meshes").onChange((value) => {
player?.setColliderDebug(value);
});
collisionFolder.open();
const characterFolder = gui.addFolder("Character");
characterFolder.add(params, "playerCapsuleDebug").name("Capsule").onChange((value) => {
player?.setPlayerCapsuleDebug?.(value);
});
const movementFolder = characterFolder.addFolder("Movement");
movementFolder.add(params, "gravity", -6000, 0, 50).name("Gravity").decimals(0).onChange((value) => player?.setGravity(value));
movementFolder.add(params, "jumpHeight", 0, 2000, 10).name("Jump Height").decimals(0).onChange((value) => player?.setJumpHeight(value));
movementFolder.add(params, "playerSpeed", 0, 20000, 10).name("Walk Speed").decimals(0).onChange((value) => player?.setPlayerSpeed(value));
movementFolder.add(params, "playerRunSpeed", 0, 20000, 10).name("Run Speed").decimals(0).onChange((value) => player?.setPlayerRunSpeed(value));
movementFolder.add(params, "flySpeed", 0, 20000, 10).name("Fly Speed").decimals(0).onChange((value) => player?.setPlayerFlySpeed(value));
movementFolder.add(params, "playerAcceleration", 1, 20000, 1).name("Acceleration").decimals(0).onChange((value) => {
if (player) player.playerAcceleration = value;
});
movementFolder.add(params, "playerDeceleration", 1, 20000, 1).name("Deceleration").decimals(0).onChange((value) => {
if (player) player.playerDeceleration = value;
});
movementFolder.add(params, "timeScale", 0, 3, 0.05).name("Time Scale").decimals(2).onChange((value) => {
if (player) player.timeScale = value;
});
movementFolder.close();
const characterCameraFolder = characterFolder.addFolder("Camera");
characterCameraFolder.add(params, "mouseSensitivity", 1, 20, 0.1).name("Mouse Sensitivity").decimals(1).onChange((value) => player?.setMouseSensitivity(value));
characterCameraFolder.add(params, "minCamDistance", 0, 200, 1).name("Min Distance").decimals(0).onChange((value) => player?.setMinCamDistance(value));
characterCameraFolder.add(params, "maxCamDistance", 50, 1000, 1).name("Max Distance").decimals(0).onChange((value) => player?.setMaxCamDistance(value));
characterCameraFolder.add(params, "camLookAtHeightRatio", 0, 1, 0.01).name("Look-at Height Ratio").decimals(2).onChange((value) => player?.setCamLookAtHeightRatio(value));
characterCameraFolder.add(params, "enableOverShoulderView").name("Over-Shoulder View").onChange((value) => player?.setOverShoulderView(value));
characterCameraFolder.add(params, "camOverShoulderOffsetRatio", -0.2, 0.2, 0.01).name("Over-Shoulder Offset").decimals(2).onChange((value) => player?.setCamOverShoulderOffsetRatio(value));
characterCameraFolder.add(params, "enableSpringCamera").name("Spring Camera").onChange((value) => {
if (player) player.cam.enableSpringCamera = value;
});
characterCameraFolder.add(params, "springCameraTime", 0.01, 1, 0.01).name("Spring Time").decimals(2).onChange((value) => {
if (player) player.cam.springCameraTime = value;
});
characterCameraFolder.add(params, "thirdMouseMode", { 0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5 }).name("Mouse Mode").onChange((value) => player?.setThirdMouseMode(Number(value)));
characterCameraFolder.add(params, "enableZoom").name("Enable Zoom").onChange((value) => player?.setEnableZoom(value));
characterCameraFolder.close();
const footIKFolder = characterFolder.addFolder("Foot IK");
footIKFolder.add(params, "footIKEnabled").name("Enabled").onChange((value) => {
footIK?.setEnabled(value);
footIK?.setDebugEnabled(value && params.footIKDebug);
});
footIKFolder.add(params, "footIKDebug").name("Debug Markers").onChange((value) => {
footIK?.setDebugEnabled(value && params.footIKEnabled);
});
footIKFolder.add(params, "straightPoleEnabled").name("Straight Pole").onChange((value) => {
applyFootIKOptions({ straightPoleEnabled: value });
});
const footIKRuntimeFolder = footIKFolder.addFolder("Runtime");
footIKRuntimeFolder.add(params, "leftFootPhase").name("Left Phase").listen().disable();
footIKRuntimeFolder.add(params, "leftFootLandIK").name("Left Land / IK").listen().disable();
footIKRuntimeFolder.add(params, "rightFootPhase").name("Right Phase").listen().disable();
footIKRuntimeFolder.add(params, "rightFootLandIK").name("Right Land / IK").listen().disable();
footIKRuntimeFolder.close();
const pelvisFolder = footIKFolder.addFolder("Pelvis");
pelvisFolder.add(params, "maxPelvisRaise", 0, 60, 1).name("Max Raise").decimals(0).onChange((value) => applyFootIKOptions({ maxPelvisRaise: value }));
pelvisFolder.add(params, "maxPelvisDrop", 0, 60, 1).name("Max Drop").decimals(0).onChange((value) => applyFootIKOptions({ maxPelvisDrop: value }));
pelvisFolder.close();
const footReachFolder = footIKFolder.addFolder("Foot Reach");
footReachFolder.add(params, "maxFootRaise", 0, 60, 1).name("Max Raise").decimals(0).onChange((value) => applyFootIKOptions({ maxFootRaise: value }));
footReachFolder.add(params, "maxFootDrop", 0, 60, 1).name("Max Drop").decimals(0).onChange((value) => applyFootIKOptions({ maxFootDrop: value }));
footReachFolder.close();
const soleFolder = footIKFolder.addFolder("Sole Layout");
soleFolder.add(params, "soleHalfWidth", 0, 24, 0.1).name("Half Width").decimals(1).onChange((value) => applyFootIKOptions({ soleHalfWidth: value }));
soleFolder.add(params, "soleToeExtend", 0, 24, 0.1).name("Toe Extend").decimals(1).onChange((value) => applyFootIKOptions({ soleToeExtend: value }));
soleFolder.add(params, "soleHeelExtend", 0, 24, 0.1).name("Heel Extend").decimals(1).onChange((value) => applyFootIKOptions({ soleHeelExtend: value }));
soleFolder.add(params, "soleSkinThickness", 0, 16, 0.1).name("Skin Thickness").decimals(1).onChange((value) => applyFootIKOptions({ soleSkinThickness: value }));
soleFolder.close();
footIKFolder.close();
characterFolder.open();
const t = VEHICLE_TUNING;
const vehicleFolder = gui.addFolder("Vehicle");
const chassisFolder = vehicleFolder.addFolder("Chassis");
chassisFolder.add(t.chassis, "clearance", 0, 2, 0.05).name("Clearance").decimals(2).onChange(applyVehicleTuning);
chassisFolder.add(t.chassis.sizeScale, "x", 0.1, 1.5, 0.05).name("Size Scale X").decimals(2).onChange(applyVehicleTuning);
chassisFolder.add(t.chassis.sizeScale, "y", 0.1, 1.5, 0.05).name("Size Scale Y").decimals(2).onChange(applyVehicleTuning);
chassisFolder.add(t.chassis.sizeScale, "z", 0.1, 1.5, 0.05).name("Size Scale Z").decimals(2).onChange(applyVehicleTuning);
chassisFolder.close();
const vehicleCameraFolder = vehicleFolder.addFolder("Camera");
vehicleCameraFolder.add(t, "followVehicleDirection").name("Follow Vehicle Heading").onChange(applyVehicleTuning);
vehicleCameraFolder.close();
const vehicleDebugFolder = vehicleFolder.addFolder("Debug");
vehicleDebugFolder.add(t.debug, "showPhysicsBox").name("Chassis Collider").onChange(applyVehicleTuning);
vehicleDebugFolder.add(t.debug, "showWheelRays").name("Wheel Rays").onChange(applyVehicleTuning);
vehicleDebugFolder.add(t.debug, "showWheelTravel").name("Suspension Travel").onChange(applyVehicleTuning);
vehicleDebugFolder.add(t.debug, "showWheelSpheres").name("Wheel Colliders").onChange(applyVehicleTuning);
vehicleDebugFolder.close();
const drivingFolder = vehicleFolder.addFolder("Driving");
drivingFolder.add(t.power, "maxSpeed", 0, 200, 1).name("Max Speed").onChange(applyVehicleTuning);
drivingFolder.add(t.power, "acceleration", 0, 10, 0.1).name("Acceleration").onChange(applyVehicleTuning);
drivingFolder.add(t.power, "deceleration", 0, 10, 0.1).name("Braking Deceleration").onChange(applyVehicleTuning);
drivingFolder.close();
const handlingFolder = vehicleFolder.addFolder("Handling");
handlingFolder.add(params, "maxSteerDeg", 5, 60, 0.5).name("Max Steering Angle (deg)").onChange((degrees) => {
t.steering.maxSteerAngle = degrees * Math.PI / 180;
applyVehicleTuning();
});
handlingFolder.add(t.steering, "steerTime", 0.1, 1.5, 0.05).name("Steering Response Time").decimals(2).onChange(applyVehicleTuning);
handlingFolder.add(t.steering, "highSpeedSteerScale", 0.1, 1, 0.01).name("High-Speed Steering Scale").decimals(2).onChange(applyVehicleTuning);
handlingFolder.add(t.grip, "maxG", 0.1, 3, 0.05).name("Max Lateral G").onChange(applyVehicleTuning);
handlingFolder.close();
const suspensionFolder = vehicleFolder.addFolder("Suspension");
suspensionFolder.add(t.suspension, "restLength", 0.05, 0.5, 0.01).name("Rest Length").decimals(2).onChange(applyVehicleTuning);
suspensionFolder.add(t.suspension, "maxTravel", 0.05, 0.5, 0.01).name("Max Travel").decimals(2).onChange(applyVehicleTuning);
suspensionFolder.add(t.suspension, "stiffness", 0.1, 50, 0.1).name("Stiffness").decimals(1).onChange(applyVehicleTuning);
suspensionFolder.close();
vehicleFolder.open();
}
function updateFootIKDebugPanel() {
if (!footIK || !footIKDebugParams) return;
footIKDebugParams.leftFootPhase = footIK.getFootPhaseDebugText("left");
footIKDebugParams.leftFootLandIK = formatFootLandIK(
footIK.getFootTimeToLand("left"),
footIK.getFootIKWeight("left"),
);
footIKDebugParams.rightFootPhase = footIK.getFootPhaseDebugText("right");
footIKDebugParams.rightFootLandIK = formatFootLandIK(
footIK.getFootTimeToLand("right"),
footIK.getFootIKWeight("right"),
);
}
function formatFootLandIK(landTime, ikWeight) {
const land = Number.isFinite(landTime) ? `${landTime.toFixed(2)}s` : "--";
const weight = Number.isFinite(ikWeight) ? ikWeight.toFixed(2) : "--";
return `${land} / ${weight}`;
}