文章目录
- 一、效果
- 二、简介
- 三、核心实现步骤
-
- [3.1 纹理资源规划与加载](#3.1 纹理资源规划与加载)
- [3.2 解决纹理拉伸的关键配置](#3.2 解决纹理拉伸的关键配置)
- [3.3 小地图轨迹模拟优化](#3.3 小地图轨迹模拟优化)
- [3.4 纹理与材质的融合应用](#3.4 纹理与材质的融合应用)
- 四、总结与后续规划
- 五、核心源码
- 六、踩坑实录------纹理拉伸问题
一、效果

- 视觉真实感:草地和道路从纯色变为带纹理的真实材质,法线纹理让草地有凹凸感,粗糙度纹理让道路的反光更符合物理规律;
- 无纹理拉伸:纹理平铺模式让纹理展示自然,细节清晰,不再出现模糊、拉伸的问题;
- 交互流畅性:小地图轨迹优化让模拟移动更平滑,与 3D 场景的视觉体验形成统一。
二、简介
在《【Web】使用 Vue3+PlayCanvas 开发 3D 游戏(八)模拟小地图实时移动》中,我们实现了小地图的轨迹模拟与实时移动效果,让 3D 场景的交互性和场景感知能力得到了提升。本次开发中,我们聚焦于视觉体验的优化,为场景中的草地和道路添加了专业的纹理效果(漫反射、法线、粗糙度),解决了纹理拉伸问题,并进一步优化了小地图的轨迹模拟逻辑,让 3D 游戏场景更贴近真实视觉效果。
在 3D 场景开发中,单纯依靠纯色材质构建地面和道路会显得非常单调,缺乏真实感。而直接使用纹理贴图时,若未做合理的平铺和 UV 缩放处理,会出现严重的纹理拉伸问题 ------ 纹理被无限制拉伸以适配模型尺寸,导致细节丢失、视觉效果失真。本次优化主要解决两个核心问题:
- 为草地和道路添加多维度纹理(漫反射、法线、粗糙度),提升材质真实感;
- 修复纹理拉伸问题,通过纹理平铺(repeat)和 UV 缩放实现自然的纹理展示效果。
三、核心实现步骤
3.1 纹理资源规划与加载
首先我们规划了草地和道路的纹理资源体系,针对不同材质特性设计了差异化的纹理组合:
- 草地纹理:漫反射(diffuse)+ 法线(normal)+ 粗糙度(rough),模拟草地的颜色、凹凸质感和反光特性;
- 道路纹理:沥青漫反射(diffuse)+ 粗糙度(rough),还原沥青路面的视觉和物理特性。
实现纹理加载的核心代码如下,通过 PlayCanvas 的 Asset 管理器批量加载纹理,并缓存供后续使用:
js
/**
* 加载纹理资源
*/
const loadTextures = async () => {
return new Promise((resolve) => {
// 加载草地纹理
const grassDiffuseAsset = new pc.Asset('grassDiffuse', 'texture', { url: '/textures/grass/diffuse.jpg' });
const grassNormalAsset = new pc.Asset('grassNormal', 'texture', { url: '/textures/grass/normal.jpg' });
const grassRoughAsset = new pc.Asset('grassRough', 'texture', { url: '/textures/grass/rough.jpg' });
// 加载道路纹理
const asphaltDiffuseAsset = new pc.Asset('asphaltDiffuse', 'texture', { url: '/textures/asphalt/difuse.jpg' });
const asphaltRoughAsset = new pc.Asset('asphaltRough', 'texture', { url: '/textures/asphalt/rough.jpg' });
// 存储所有纹理资产
const textureAssets = [
grassDiffuseAsset, grassNormalAsset, grassRoughAsset,
asphaltDiffuseAsset, asphaltRoughAsset
];
// 添加到资产管理器
textureAssets.forEach(asset => app.assets.add(asset));
// 加载完成处理
let loadedCount = 0;
const onTextureLoaded = () => {
loadedCount++;
if (loadedCount === textureAssets.length) {
// 缓存纹理引用
grassDiffuseTexture = grassDiffuseAsset.resource;
grassNormalTexture = grassNormalAsset.resource;
grassRoughTexture = grassRoughAsset.resource;
asphaltDiffuseTexture = asphaltDiffuseAsset.resource;
asphaltRoughTexture = asphaltRoughAsset.resource;
// 关键修复:开启纹理重复模式,解决拉伸
if (grassDiffuseTexture) {
grassDiffuseTexture.addressU = pc.ADDRESS_REPEAT;
grassDiffuseTexture.addressV = pc.ADDRESS_REPEAT;
}
if (grassNormalTexture) {
grassNormalTexture.addressU = pc.ADDRESS_REPEAT;
grassNormalTexture.addressV = pc.ADDRESS_REPEAT;
}
if (grassRoughTexture) {
grassRoughTexture.addressU = pc.ADDRESS_REPEAT;
grassRoughTexture.addressV = pc.ADDRESS_REPEAT;
}
if (asphaltDiffuseTexture) {
asphaltDiffuseTexture.addressU = pc.ADDRESS_REPEAT;
asphaltDiffuseTexture.addressV = pc.ADDRESS_REPEAT;
}
if (asphaltRoughTexture) {
asphaltRoughTexture.addressU = pc.ADDRESS_REPEAT;
asphaltRoughTexture.addressV = pc.ADDRESS_REPEAT;
}
resolve();
}
};
// 绑定加载事件
textureAssets.forEach(asset => {
asset.on('load', onTextureLoaded);
asset.on('error', (err) => {
console.warn(`纹理${asset.name}加载失败,使用默认颜色:`, err);
onTextureLoaded(); // 继续流程
});
app.assets.load(asset);
});
});
};
3.2 解决纹理拉伸的关键配置
纹理拉伸的核心原因是纹理的寻址模式默认是 "拉伸(ADDRESS_CLAMP_TO_EDGE)",我们将其修改为 "重复(ADDRESS_REPEAT)",让纹理在模型表面平铺展示:
js
// 开启纹理重复模式,解决拉伸
grassDiffuseTexture.addressU = pc.ADDRESS_REPEAT;
grassDiffuseTexture.addressV = pc.ADDRESS_REPEAT;
该配置让纹理在 U、V 两个方向上重复平铺,配合后续的 UV 缩放,可以控制纹理的平铺密度,避免纹理过大或过小。
3.3 小地图轨迹模拟优化
在纹理优化的同时,我们也优化了小地图的模拟移动轨迹数组,让轨迹点的分布更合理,模拟移动更平滑:
js
// 定义模拟轨迹数组(优化后)
const latLngTrack = [
{ lat: 29.440775, lng: 106.521942 }, // 起点(中心位置)
{ lat: 29.440785, lng: 106.521952 },
{ lat: 29.440795, lng: 106.521962 },
{ lat: 29.440805, lng: 106.521972 },
{ lat: 29.440815, lng: 106.521982 },
{ lat: 29.440825, lng: 106.521992 },
{ lat: 29.440835, lng: 106.522002 },
{ lat: 29.440845, lng: 106.522012 },
{ lat: 29.440855, lng: 106.522022 },
{ lat: 29.440865, lng: 106.522032 },
{ lat: 29.440875, lng: 106.522042 },
{ lat: 29.440885, lng: 106.522052 },
{ lat: 29.440895, lng: 106.522062 },
{ lat: 29.440905, lng: 106.522072 },
{ lat: 29.440915, lng: 106.522082 },
];
优化后的轨迹点采用线性递增的经纬度偏移,模拟角色沿固定方向的移动,配合 300ms 的刷新间隔(UPDATE_INTERVAL),让小地图上的角色移动更流畅。
3.4 纹理与材质的融合应用
在创建地面和道路时,将加载的纹理绑定到材质的对应属性上,实现多维度的视觉效果:
- 漫反射纹理(diffuseMap):控制基础颜色;
- 法线纹理(normalMap):模拟表面凹凸,增强立体感;
- 粗糙度纹理(roughnessMap):控制反光特性,模拟真实的物理材质。
核心逻辑示例:
js
// 构建草地材质
const grassMaterial = new pc.StandardMaterial();
grassMaterial.diffuseMap = grassDiffuseTexture;
grassMaterial.normalMap = grassNormalTexture;
grassMaterial.roughnessMap = grassRoughTexture;
grassMaterial.useLighting = true;
grassMaterial.update();
// 构建道路材质
const asphaltMaterial = new pc.StandardMaterial();
asphaltMaterial.diffuseMap = asphaltDiffuseTexture;
asphaltMaterial.roughnessMap = asphaltRoughTexture;
asphaltMaterial.useLighting = true;
asphaltMaterial.update();
四、总结与后续规划
本次开发完成了 3D 场景纹理系统的核心优化,解决了视觉层面的关键问题,让场景更具真实感。后续我们将继续聚焦以下方向:
- 优化纹理的 UV 缩放逻辑,精细化控制纹理平铺密度;
- 为更多模型(如车辆、树木)添加纹理和材质优化;
- 结合物理引擎,让纹理与碰撞、光影系统深度融合;
- 优化纹理加载的性能,实现纹理的懒加载和缓存策略。
五、核心源码
html
<template>
<div class="game-container">
<canvas id="app-container" ref="canvasContainer"></canvas>
<div class="game-info">
当前角色位置:X: {{ playerX.toFixed(1) }} | 已加载障碍物:{{ obstacleCount }}
<br>路宽:{{ ROAD_WIDTH }}m | 小车行驶速度:{{ TREE_SCROLL_SPEED }}m/s
<br>操作说明:左键旋转 | 右键平移 | 滚轮缩放 | 左键双击恢复视角
</div>
<div class="ros-image-panel">
<h4>ROS实时画面 ({{ imageFps }} FPS)</h4>
<img ref="rosImage" class="ros-image" alt="ROS 摄像头画面" />
</div>
<!-- 集成小地图组件 -->
<MiniMapCircle
:centerLat="centerLat"
:centerLng="centerLng"
:currentLat="currentLat"
:currentLng="currentLng"
:scale="50"
/>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue';
import * as pc from 'playcanvas';
import MiniMapCircle from './MiniMapCircle.vue';
// 容器引用
const canvasContainer = ref(null);
const rosImage = ref(null);
// 地图中心经纬度
const centerLat = ref(29.440875)
const centerLng = ref(106.521942)
// 当前角色经纬度(模拟:基于3D X/Z坐标偏移)
const currentLat = ref(centerLat.value);
const currentLng = ref(centerLng.value);
// 定义模拟轨迹数组(可以根据实际需求调整坐标点和数量)
const latLngTrack = [
{ lat: 29.440775, lng: 106.521942 }, // 起点(中心位置)
{ lat: 29.440785, lng: 106.521952 },
{ lat: 29.440795, lng: 106.521962 },
{ lat: 29.440805, lng: 106.521972 },
{ lat: 29.440815, lng: 106.521982 },
{ lat: 29.440825, lng: 106.521992 },
{ lat: 29.440835, lng: 106.522002 },
{ lat: 29.440845, lng: 106.522012 },
{ lat: 29.440855, lng: 106.522022 },
{ lat: 29.440865, lng: 106.522032 },
{ lat: 29.440875, lng: 106.522042 },
{ lat: 29.440885, lng: 106.522052 },
{ lat: 29.440895, lng: 106.522062 },
{ lat: 29.440905, lng: 106.522072 },
{ lat: 29.440915, lng: 106.522082 },
];
// 轨迹控制变量
let pointIndex = 0; // 当前走到第几个点
let mapTimer = null; // 定时器
const UPDATE_INTERVAL = 300; // 刷新速度(越小移动越快)
// PlayCanvas核心实例
let app = null;
let camera = null;
let player = null;
// 模型模板缓存
let carTemplateBlue = null;
let carTemplateBlack = null;
let truckTemplate = null;
let personTemplate = null;
let dogTemplate = null;
let barrierTemplate = null;
let treeTemplate = null; // 新增:树模板
// 纹理资源缓存
let grassDiffuseTexture = null;
let grassNormalTexture = null;
let grassRoughTexture = null;
let asphaltDiffuseTexture = null;
let asphaltRoughTexture = null;
// 状态变量
const playerX = ref(0);
const obstacleCount = ref(0);
let obstacleEntities = new Map();
let obstacleRawData = new Map();
let treeEntities = []; // 新增:存储树实体
let treeOffsetZ = 0; // 新增:树木滚动偏移量
// WebSocket实例(纯原生,无Webviz依赖)
let obstacleWs = null;
let rosImageWs = null;
// 相机控制变量
let isMouseLeftDown = false;
let isMouseRightDown = false;
let lastMouseX = 0;
let lastMouseY = 0;
let cameraDistance = 10;
let cameraYaw = 0;
let cameraPitch = 20;
const DEFAULT_CAMERA_DISTANCE = 10;
const DEFAULT_CAMERA_YAW = 0;
const DEFAULT_CAMERA_PITCH = 20;
// 双击检测
let clickTimer = null;
let clickCount = 0;
// WebSocket重试配置(仅控制连接重试,无库检查)
const WS_MAX_RETRY = 5;
let obstacleWsRetryCount = 0;
let imageWsRetryCount = 0;
// ========== 图片流优化新增变量 ==========
const imageFps = ref(0); // 显示当前帧率
let lastRenderTime = 0; // 上次渲染时间
const RENDER_INTERVAL = 20; // 渲染间隔(ms) - 20fps
let fpsCache = [];
const MAX_FPS_CACHE = 5; // 取最近5帧的平均帧率
let imageUrlPool = new Set(); // 追踪URL,批量释放
// ========== 核心配置:可调整常量 ==========
// 障碍物距离过滤配置(改为仅用道路范围过滤)
const OBSTACLE_MIN_DISTANCE = 0; // 重置最小距离
const OBSTACLE_MAX_DISTANCE = 1000; // 重置最大距离
// 道路配置(修复颜色问题+可调整路宽)
const ROAD_WIDTH = 15; // 道路宽度(单位:m)
const ROAD_LENGTH = 800; // 道路长度(单位:m)
const ROAD_COLOR = new pc.Color(0.35, 0.35, 0.4); // 道路颜色(深灰色,固定)
const GROUND_COLOR = new pc.Color(0.2, 0.6, 0.2); // 路外地面颜色(绿色)
// 树木配置(新增滚动效果)
const TREE_SPACING = 50; // 树木间隔距离
const TREE_OFFSET = ROAD_WIDTH / 2 + 5; // 树木离道路边缘的距离
const TREE_SCROLL_SPEED = 8; // 树木滚动速度(m/s),后续可调整
// ========== 核心配置:新增模型比例锁定常量 ==========
const CAR_MODEL_SCALE = 0.4; // 轿车模型基础缩放(等比)
const CAR_ASPECT_RATIO = 1.8; // 轿车宽长比(根据实际模型调整,默认1.8:1)
// ====================== 工具函数 ======================
/**
* 角度转弧度
*/
const degToRad = (degrees) => {
return degrees * Math.PI / 180;
};
/**
* 恢复默认视角
*/
const resetCameraView = () => {
cameraDistance = DEFAULT_CAMERA_DISTANCE;
cameraYaw = DEFAULT_CAMERA_YAW;
cameraPitch = DEFAULT_CAMERA_PITCH;
treeOffsetZ = 0; // 重置树木偏移
if (player) {
player.setPosition(0, 0.1, 0);
playerX.value = 0;
}
updateCameraPosition();
updateTreePositions(); // 刷新树木位置
console.log('视角已恢复默认值');
};
/**
* 优化版ROS图片流渲染(节流+内存优化)
*/
const renderRosImage = (data) => {
const now = Date.now();
// 节流控制:避免高频渲染
if (now - lastRenderTime < RENDER_INTERVAL) {
return;
}
const timeDiff = now - lastRenderTime;
lastRenderTime = now;
try {
const blob = new Blob([data], { type: 'image/jpeg' });
const imageUrl = URL.createObjectURL(blob);
if (rosImage.value) {
// 内存优化:释放旧URL
if (rosImage.value.src && imageUrlPool.has(rosImage.value.src)) {
URL.revokeObjectURL(rosImage.value.src);
imageUrlPool.delete(rosImage.value.src);
}
rosImage.value.src = imageUrl;
imageUrlPool.add(imageUrl);
// 计算FPS(防除零)
if (timeDiff > 0) {
const currentFps = Math.round(1000 / timeDiff);
fpsCache.push(currentFps);
if (fpsCache.length > MAX_FPS_CACHE) fpsCache.shift();
const avgFps = Math.round(fpsCache.reduce((sum, fps) => sum + fps, 0) / fpsCache.length);
imageFps.value = Math.max(1, Math.min(60, avgFps)); // 限制FPS范围1-60
} else {
imageFps.value = imageFps.value || 20; // 兜底默认值
}
}
} catch (error) {
console.error('解析ROS图片失败:', error);
imageFps.value = 0;
}
};
/**
* 批量释放所有图片URL
*/
const releaseAllImageUrls = () => {
imageUrlPool.forEach(url => {
try {
URL.revokeObjectURL(url);
} catch (e) {
console.warn('释放URL失败:', e);
}
});
imageUrlPool.clear();
};
// ====================== 纹理加载函数(新增) ======================
/**
* 加载纹理资源
*/
const loadTextures = async () => {
return new Promise((resolve) => {
// 加载草地纹理
const grassDiffuseAsset = new pc.Asset('grassDiffuse', 'texture', { url: '/textures/grass/diffuse.jpg' });
const grassNormalAsset = new pc.Asset('grassNormal', 'texture', { url: '/textures/grass/normal.jpg' });
const grassRoughAsset = new pc.Asset('grassRough', 'texture', { url: '/textures/grass/rough.jpg' });
// 加载道路纹理
const asphaltDiffuseAsset = new pc.Asset('asphaltDiffuse', 'texture', { url: '/textures/asphalt/difuse.jpg' });
const asphaltRoughAsset = new pc.Asset('asphaltRough', 'texture', { url: '/textures/asphalt/rough.jpg' });
// 存储所有纹理资产
const textureAssets = [
grassDiffuseAsset, grassNormalAsset, grassRoughAsset,
asphaltDiffuseAsset, asphaltRoughAsset
];
// 添加到资产管理器
textureAssets.forEach(asset => app.assets.add(asset));
// 加载完成处理
let loadedCount = 0;
const onTextureLoaded = () => {
loadedCount++;
if (loadedCount === textureAssets.length) {
// 缓存纹理引用
grassDiffuseTexture = grassDiffuseAsset.resource;
grassNormalTexture = grassNormalAsset.resource;
grassRoughTexture = grassRoughAsset.resource;
asphaltDiffuseTexture = asphaltDiffuseAsset.resource;
asphaltRoughTexture = asphaltRoughAsset.resource;
// ✅ 关键修复:开启纹理重复模式,解决拉伸
if (grassDiffuseTexture) {
grassDiffuseTexture.addressU = pc.ADDRESS_REPEAT;
grassDiffuseTexture.addressV = pc.ADDRESS_REPEAT;
}
if (grassNormalTexture) {
grassNormalTexture.addressU = pc.ADDRESS_REPEAT;
grassNormalTexture.addressV = pc.ADDRESS_REPEAT;
}
if (grassRoughTexture) {
grassRoughTexture.addressU = pc.ADDRESS_REPEAT;
grassRoughTexture.addressV = pc.ADDRESS_REPEAT;
}
if (asphaltDiffuseTexture) {
asphaltDiffuseTexture.addressU = pc.ADDRESS_REPEAT;
asphaltDiffuseTexture.addressV = pc.ADDRESS_REPEAT;
}
if (asphaltRoughTexture) {
asphaltRoughTexture.addressU = pc.ADDRESS_REPEAT;
asphaltRoughTexture.addressV = pc.ADDRESS_REPEAT;
}
resolve();
}
};
// 绑定加载事件
textureAssets.forEach(asset => {
asset.on('load', onTextureLoaded);
asset.on('error', (err) => {
console.warn(`纹理${asset.name}加载失败,使用默认颜色:`, err);
onTextureLoaded(); // 继续流程
});
app.assets.load(asset);
});
});
};
// ====================== ROS WebSocket 初始化 ======================
/**
* 初始化障碍物数据WebSocket
*/
const initObstacleWebSocket = () => {
obstacleWsRetryCount = 0;
if (obstacleWs) {
obstacleWs.close();
obstacleWs = null;
}
const wsUrl = 'ws://10.27.9.81:9090/obstacle_objs';
obstacleWs = new WebSocket(wsUrl);
obstacleWs.onopen = () => {
console.log('障碍物WebSocket连接成功');
obstacleWsRetryCount = 0;
obstacleWs.send(JSON.stringify({
op: 'subscribe',
topic: '/obstacle_objs'
}));
};
obstacleWs.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (!data.msg || !Array.isArray(data.msg.objs)) {
console.warn('障碍物数据格式错误:', data);
return;
}
const obstacleList = data.msg.objs;
const currentValidIds = new Set();
obstacleList.forEach(item => {
const obstacleData = {
id: item.track_id,
type: item.type,
position: {
x: item.x,
z: item.y
},
size: {
x: item.width,
y: item.height,
z: item.length
},
heading: item.heading,
isActive: true
};
currentValidIds.add(obstacleData.id);
handleObstacleData(obstacleData);
});
cleanupInvalidObstacles(currentValidIds);
} catch (error) {
console.error('解析障碍物数据失败:', error);
}
};
obstacleWs.onerror = (error) => {
console.error('障碍物WebSocket错误:', error);
};
obstacleWs.onclose = (event) => {
if (event.code !== 1000 && obstacleWsRetryCount < WS_MAX_RETRY) {
obstacleWsRetryCount++;
const retryDelay = 1000 * obstacleWsRetryCount;
console.log(`障碍物WebSocket断开,${retryDelay/1000}秒后重试(${obstacleWsRetryCount}/${WS_MAX_RETRY})`);
setTimeout(initObstacleWebSocket, retryDelay);
} else {
console.log('障碍物WebSocket正常关闭或达到最大重试次数');
}
};
};
/**
* 初始化ROS图片流WebSocket
*/
const initRosImageWebSocket = () => {
imageWsRetryCount = 0;
if (rosImageWs) {
rosImageWs.close();
rosImageWs = null;
}
const wsUrl = 'ws://10.27.9.81:9090/usb_cam/image_raw/compressed';
rosImageWs = new WebSocket(wsUrl);
rosImageWs.binaryType = 'arraybuffer';
rosImageWs.onopen = () => {
console.log('ROS图片流WebSocket连接成功');
imageWsRetryCount = 0;
rosImageWs.send(JSON.stringify({
op: 'subscribe',
topic: '/usb_cam/image_raw/compressed'
}));
};
rosImageWs.onmessage = (event) => {
try {
let rawData = event.data;
if (rawData instanceof ArrayBuffer) {
rawData = new TextDecoder().decode(rawData);
}
const rosMsg = JSON.parse(rawData);
const base64Image = rosMsg.msg?.data;
if (!base64Image) {
console.warn('ROS图片数据为空(单帧):', rosMsg);
return;
}
const binaryStr = atob(base64Image);
const bytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i);
}
renderRosImage(bytes.buffer);
} catch (error) {
console.error('处理ROS图片数据失败:', error);
}
};
rosImageWs.onerror = (error) => {
console.error('ROS图片流WebSocket错误:', error);
};
rosImageWs.onclose = (event) => {
if (event.code !== 1000 && imageWsRetryCount < WS_MAX_RETRY) {
imageWsRetryCount++;
const retryDelay = 1000 * imageWsRetryCount;
console.log(`图片流WebSocket断开,${retryDelay/1000}秒后重试(${imageWsRetryCount}/${WS_MAX_RETRY})`);
setTimeout(initRosImageWebSocket, retryDelay);
} else {
console.log('图片流WebSocket正常关闭或达到最大重试次数');
releaseAllImageUrls();
}
};
};
// ====================== 障碍物数据处理 ======================
/**
* 映射障碍物类型
*/
const mapTypeToModel = (type) => {
const typeMap = {
0: 'car',
1: 'truck',
5: 'barrier',
8: 'person',
};
return typeMap[type] || 'unknown';
};
/**
* 清理无效障碍物
*/
const cleanupInvalidObstacles = (validIds) => {
Array.from(obstacleEntities.keys()).forEach(id => {
if (!validIds.has(id)) {
removeObstacle(id);
}
});
};
/**
* 处理障碍物数据(仅显示道路内的障碍物)
*/
const handleObstacleData = (obstacleData) => {
if (!obstacleData.id) return;
// 过滤重复数据
const existingData = obstacleRawData.get(obstacleData.id);
if (existingData) {
const posEqual = existingData.position.x === obstacleData.position.x &&
existingData.position.z === obstacleData.position.z;
const typeEqual = existingData.type === obstacleData.type;
if (posEqual && typeEqual) return;
}
obstacleRawData.set(obstacleData.id, obstacleData);
// ROS坐标系向右旋转90度转换
const playerPos = player.getPosition();
const rosX = obstacleData.position.x;
const rosY = obstacleData.position.z;
const rotatedX = rosY;
const rotatedZ = -rosX;
const relativeX = -rotatedX - playerPos.x;
const relativeZ = rotatedZ - playerPos.z;
// 仅检查是否在道路范围内(X轴),忽略距离过滤
const halfRoadWidth = ROAD_WIDTH / 2;
const isInRoad = Math.abs(relativeX) <= halfRoadWidth;
if (obstacleData.isActive && isInRoad) {
updateObstacle(obstacleData, relativeX, relativeZ);
} else {
removeObstacle(obstacleData.id);
}
};
/**
* 创建默认障碍物模型
*/
const createDefaultObstacle = (parent) => {
const defaultEntity = new pc.Entity('default-obstacle');
defaultEntity.addComponent('model', { type: 'box' });
const mat = new pc.StandardMaterial();
mat.diffuse = new pc.Color(1, 0, 0);
mat.roughness = 0.8;
mat.update();
defaultEntity.model.material = mat;
defaultEntity.setLocalScale(1, 1, 1);
parent.addChild(defaultEntity);
};
/**
* 更新/创建障碍物实体
*/
const updateObstacle = (data, relativeX, relativeZ) => {
let obstacleEntity = obstacleEntities.get(data.id);
if (!obstacleEntity) {
obstacleEntity = new pc.Entity(`obstacle-${data.id}`);
obstacleEntity.collisionType = data.type;
const modelType = mapTypeToModel(data.type);
let modelCreated = false;
switch (modelType) {
case 'car':
if (carTemplateBlue && carTemplateBlack) {
// 关键修复2:克隆模板时保留原始等比缩放
const carEntity = Math.random() > 0.5 ? carTemplateBlue.clone() : carTemplateBlack.clone();
// 强制重置缩放为等比基础值,避免继承异常缩放
carEntity.setLocalScale(CAR_MODEL_SCALE, CAR_MODEL_SCALE, CAR_MODEL_SCALE / CAR_ASPECT_RATIO);
obstacleEntity.addChild(carEntity);
modelCreated = true;
}
break;
case 'truck':
if (truckTemplate) {
makeTruck(obstacleEntity);
modelCreated = true;
}
break;
case 'person':
if (personTemplate) {
makePerson(obstacleEntity);
modelCreated = true;
}
break;
case 'barrier':
if (barrierTemplate) {
makeBarrier(obstacleEntity);
modelCreated = true;
}
break;
case 'unknown':
createDefaultObstacle(obstacleEntity);
modelCreated = true;
break;
}
if (!modelCreated) {
createDefaultObstacle(obstacleEntity);
}
app.root.addChild(obstacleEntity);
obstacleEntities.set(data.id, obstacleEntity);
obstacleCount.value = obstacleEntities.size;
}
if (!obstacleEntity) {
console.warn(`障碍物${data.id}创建失败`);
return;
}
// 设置位置和缩放
if(data.type === 1) {
obstacleEntity.setPosition(relativeX, 3, relativeZ);
} else {
obstacleEntity.setPosition(relativeX, 0.1, relativeZ);
}
if (data.size) {
const modelType = mapTypeToModel(data.type);
if (modelType !== 'car') { // 轿车跳过尺寸缩放,保持固定比例
const scaleX = Math.max(0.1, data.size.x || 1);
const scaleY = Math.max(0.1, data.size.y || 1);
const scaleZ = Math.max(0.1, data.size.z || 1);
if(data.type === 8) {
obstacleEntity.setLocalScale(scaleX, scaleY, scaleZ);
}
} else {
// 轿车强制重置为等比缩放,忽略异常尺寸
obstacleEntity.children[0].setLocalScale(
CAR_MODEL_SCALE,
CAR_MODEL_SCALE,
CAR_MODEL_SCALE / CAR_ASPECT_RATIO
);
}
}
// 航向角补偿
if (data.heading) {
const compensatedHeading = data.heading - 180;
obstacleEntity.setLocalEulerAngles(0, -compensatedHeading, 0);
}
obstacleEntity.visible = true;
};
/**
* 移除障碍物
*/
const removeObstacle = (id) => {
const obstacleEntity = obstacleEntities.get(id);
if (obstacleEntity) {
app.root.removeChild(obstacleEntity);
obstacleEntity.destroy();
obstacleEntities.delete(id);
obstacleRawData.delete(id);
obstacleCount.value = obstacleEntities.size;
}
};
// ====================== 鼠标控制 ======================
const initMouseControls = () => {
const canvas = canvasContainer.value;
if (!canvas) return; // 增加空值判断
// 鼠标按下
canvas.addEventListener('mousedown', (e) => {
e.preventDefault();
// 双击检测
if (e.button === 0) {
clickCount++;
if (clickCount === 1) {
clickTimer = setTimeout(() => {
clickCount = 0;
}, 300);
} else if (clickCount === 2) {
clearTimeout(clickTimer);
clickCount = 0;
resetCameraView();
return;
}
}
// 左键旋转相机
if (e.button === 0 && clickCount === 1) {
isMouseLeftDown = true;
lastMouseX = e.clientX;
lastMouseY = e.clientY;
canvas.style.cursor = 'grabbing';
}
// 右键平移角色
else if (e.button === 2) {
isMouseRightDown = true;
lastMouseX = e.clientX;
lastMouseY = e.clientY;
canvas.style.cursor = 'grabbing';
}
});
// 鼠标松开
window.addEventListener('mouseup', (e) => {
if (e.button === 0 || e.button === 2) {
isMouseLeftDown = false;
isMouseRightDown = false;
if (canvas) {
canvas.style.cursor = 'grab';
}
}
});
// 鼠标移出
canvas.addEventListener('mouseout', () => {
isMouseLeftDown = false;
isMouseRightDown = false;
canvas.style.cursor = 'grab';
clearTimeout(clickTimer);
clickCount = 0;
});
// 鼠标移动
window.addEventListener('mousemove', (e) => {
if (!camera || !player) return;
const deltaX = e.clientX - lastMouseX;
const deltaY = e.clientY - lastMouseY;
// 左键旋转相机
if (isMouseLeftDown) {
cameraYaw -= deltaX * 0.5;
cameraPitch = Math.max(0, Math.min(80, cameraPitch + deltaY * 0.5));
updateCameraPosition();
}
// 右键平移角色
else if (isMouseRightDown) {
const moveSpeed = 0.1;
let playerPos = player.getPosition();
const yawRad = degToRad(cameraYaw);
playerPos.x -= deltaX * moveSpeed * Math.cos(yawRad) + deltaY * moveSpeed * Math.sin(yawRad);
playerPos.z += deltaY * moveSpeed * Math.cos(yawRad) - deltaX * moveSpeed * Math.sin(yawRad);
// 限制角色在道路内移动
const halfRoadWidth = ROAD_WIDTH / 2;
playerPos.x = Math.max(-halfRoadWidth, Math.min(halfRoadWidth, playerPos.x));
playerPos.z = Math.max(-100, Math.min(100, playerPos.z));
player.setPosition(playerPos);
playerX.value = playerPos.x;
updateCameraPosition();
}
lastMouseX = e.clientX;
lastMouseY = e.clientY;
});
// 滚轮缩放
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
cameraDistance = Math.max(2, Math.min(30, cameraDistance - e.deltaY * 0.01));
updateCameraPosition();
});
// 阻止右键菜单
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
canvas.style.cursor = 'grab';
};
/**
* 更新相机位置
*/
const updateCameraPosition = () => {
if (!camera || !player) return;
const playerPos = player.getPosition();
const yawRad = degToRad(cameraYaw);
const pitchRad = degToRad(cameraPitch);
const cameraX = playerPos.x + cameraDistance * Math.sin(yawRad) * Math.cos(pitchRad);
const cameraZ = playerPos.z + cameraDistance * Math.cos(yawRad) * Math.cos(pitchRad);
const cameraY = playerPos.y + 2 + cameraDistance * Math.sin(pitchRad);
camera.setPosition(cameraX, cameraY, cameraZ);
camera.lookAt(playerPos.x, playerPos.y + 1, playerPos.z);
};
// ====================== 模型加载 ======================
/**
* 加载角色模型
*/
const loadDogModel = async () => {
return new Promise((resolve, reject) => {
const modelUrl = new URL('/download/car/car.glb', import.meta.url).href;
const asset = new pc.Asset('dog', 'model', { url: modelUrl }, { preload: true });
app.assets.add(asset);
asset.on('load', () => {
const dogEntity = new pc.Entity('dog-template');
dogEntity.addComponent('model', {
type: 'asset', asset: asset, castShadows: true, receiveShadows: true
});
dogEntity.setLocalScale(0.5, 0.5, 0.5);
dogEntity.setLocalEulerAngles(-90, -180, 0);
setTimeout(() => {
const updateMaterials = (entity) => {
if (entity.model?.meshInstances) {
entity.model.meshInstances.forEach(mi => {
if (mi.material) {
mi.material.useLighting = true;
mi.material.roughness = 0.5;
mi.material.metalness = 0.1;
mi.material.update();
}
});
}
entity.children.forEach(updateMaterials);
};
updateMaterials(dogEntity);
}, 200);
dogTemplate = dogEntity;
resolve();
});
asset.on('error', (err) => {
console.error('加载角色模型失败:', err);
reject(err);
});
app.assets.load(asset);
});
};
/**
* 加载轿车模型
*/
const loadCarModels = async () => {
return new Promise((resolve, reject) => {
const modelUrl = new URL('/download/car/car2.glb', import.meta.url).href;
const asset = new pc.Asset('car', 'model', { url: modelUrl }, { preload: true });
app.assets.add(asset);
asset.on('load', () => {
const createCar = (colorName, diffuseColor) => {
const carEntity = new pc.Entity(`car-${colorName}`);
carEntity.addComponent('model', {
type: 'asset', asset: asset, castShadows: true, receiveShadows: true
});
// 关键修复1:等比缩放,锁定宽高比
carEntity.setLocalScale(CAR_MODEL_SCALE, CAR_MODEL_SCALE, CAR_MODEL_SCALE / CAR_ASPECT_RATIO);
setTimeout(() => {
const updateMaterial = (entity) => {
if (entity.model?.meshInstances) {
entity.model.meshInstances.forEach(mi => {
const mat = mi.material.clone();
mat.useLighting = true;
mat.diffuse = diffuseColor;
mat.specular = new pc.Color(0.2, 0.2, 0.2);
mat.roughness = 0.3;
mat.metalness = 0.7;
mat.update();
mi.material = mat;
});
}
entity.children.forEach(updateMaterial);
};
updateMaterial(carEntity);
}, 200);
return carEntity;
};
carTemplateBlue = createCar('blue', new pc.Color(0.1, 0.3, 0.8));
carTemplateBlack = createCar('black', new pc.Color(0.1, 0.1, 0.1));
console.log('轿车模型加载完成(等比缩放)');
resolve();
});
asset.on('error', (err) => {
console.error('加载轿车模型失败:', err);
reject(err);
});
app.assets.load(asset);
});
};
/**
* 加载货车模型
*/
const loadTruckModel = async () => {
return new Promise((resolve, reject) => {
const modelUrl = new URL('/download/truck/truck2.glb', import.meta.url).href;
const asset = new pc.Asset('truck', 'model', { url: modelUrl }, { preload: true });
app.assets.add(asset);
asset.on('load', () => {
const truckEntity = new pc.Entity('truck-template');
truckEntity.addComponent('model', {
type: 'asset', asset: asset, castShadows: true, receiveShadows: true
});
truckEntity.setLocalEulerAngles(-90, 0, 0);
truckEntity.setLocalScale(0.6, 0.6, 0.6);
setTimeout(() => {
const updateMaterial = (entity) => {
if (entity.model?.meshInstances) {
entity.model.meshInstances.forEach(mi => {
mi.material.useLighting = true;
mi.material.diffuse = new pc.Color(0.3, 0.3, 0.3);
mi.material.update();
});
}
entity.children.forEach(updateMaterial);
};
updateMaterial(truckEntity);
}, 200);
truckTemplate = truckEntity;
resolve();
});
asset.on('error', (err) => {
console.error('加载货车模型失败:', err);
reject(err);
});
app.assets.load(asset);
});
};
/**
* 加载人物模型
*/
const loadPersonModel = async () => {
return new Promise((resolve, reject) => {
const modelUrl = new URL('/download/person/person.glb', import.meta.url).href;
const asset = new pc.Asset('person', 'model', { url: modelUrl }, { preload: true });
app.assets.add(asset);
asset.on('load', () => {
const personEntity = new pc.Entity('person-template');
personEntity.addComponent('model', {
type: 'asset', asset: asset, castShadows: true, receiveShadows: true
});
personEntity.setLocalScale(1.0, 1.0, 1.0);
setTimeout(() => {
const updateMaterial = (entity) => {
if (entity.model?.meshInstances) {
entity.model.meshInstances.forEach(mi => {
mi.material.useLighting = true;
mi.material.diffuse = new pc.Color(0.9, 0.7, 0.1);
mi.material.update();
});
}
entity.children.forEach(updateMaterial);
};
updateMaterial(personEntity);
}, 200);
personTemplate = personEntity;
resolve();
});
asset.on('error', (err) => {
console.error('加载人物模型失败:', err);
reject(err);
});
app.assets.load(asset);
});
};
/**
* 加载路障模型
*/
const loadBarrierModel = async () => {
return new Promise((resolve, reject) => {
const modelUrl = new URL('/download/barrier/barrier.glb', import.meta.url).href;
const asset = new pc.Asset('barrier', 'model', { url: modelUrl }, { preload: true });
app.assets.add(asset);
asset.on('load', () => {
const barrierEntity = new pc.Entity('barrier-template');
barrierEntity.addComponent('model', {
type: 'asset', asset: asset, castShadows: true, receiveShadows: true
});
barrierEntity.setLocalScale(0.5, 0.5, 0.3);
barrierEntity.setLocalEulerAngles(0, 90, 0);
setTimeout(() => {
const updateMaterial = (entity) => {
if (entity.model?.meshInstances) {
entity.model.meshInstances.forEach(mi => {
mi.material.useLighting = true;
mi.material.diffuse = new pc.Color(0.4, 0.25, 0.1);
mi.material.update();
});
}
entity.children.forEach(updateMaterial);
};
updateMaterial(barrierEntity);
}, 200);
barrierTemplate = barrierEntity;
resolve();
});
asset.on('error', (err) => {
console.error('加载路障模型失败:', err);
reject(err);
});
app.assets.load(asset);
});
};
/**
* 加载树模型
*/
const loadTreeModel = async () => {
return new Promise((resolve, reject) => {
const modelUrl = new URL('/download/tree/christmastree.glb', import.meta.url).href;
const asset = new pc.Asset(
'christmastree',
'model',
{ url: modelUrl },
{ preload: true }
);
app.assets.add(asset);
asset.on('load', () => {
const treeEntity = new pc.Entity('tree-template');
treeEntity.addComponent('model', {
type: 'asset',
asset: asset,
castShadows: true,
receiveShadows: true
});
// 调整模型旋转
treeEntity.setLocalEulerAngles(-90, 0, 0);
treeEntity.setLocalScale(1.6, 1.6, 1.6);
// 手动设置材质颜色
setTimeout(() => {
if (treeEntity.model && treeEntity.model.meshInstances) {
treeEntity.model.meshInstances.forEach((mi, index) => {
if (mi.material) {
// 强制启用材质渲染
mi.material.useLighting = true;
mi.material.diffuseMap = mi.material.diffuseMap || null;
mi.material.specular = new pc.Color(0.1, 0.1, 0.1);
mi.material.roughness = 0.4;
mi.material.metalness = 0.0;
// 区分树干和树枝,设置不同颜色
if (index === 0) {
// 树干:棕色系
mi.material.diffuse = new pc.Color(0.4, 0.25, 0.1);
mi.material.emissive = new pc.Color(0.05, 0.03, 0.01);
} else {
// 树枝:绿色系
mi.material.diffuse = new pc.Color(0.15, 0.6, 0.2);
mi.material.emissive = new pc.Color(0.02, 0.08, 0.03);
}
mi.material.update();
}
});
}
// 递归处理子节点的材质
const updateChildMaterials = (entity) => {
if (entity.children.length === 0) return;
entity.children.forEach(child => {
if (child.model && child.model.meshInstances) {
child.model.meshInstances.forEach((mi, index) => {
if (mi.material) {
mi.material.useLighting = true;
if (index === 0) {
mi.material.diffuse = new pc.Color(0.4, 0.25, 0.1);
mi.material.emissive = new pc.Color(0.05, 0.03, 0.01);
} else {
mi.material.diffuse = new pc.Color(0.15, 0.6, 0.2);
mi.material.emissive = new pc.Color(0.02, 0.08, 0.03);
}
mi.material.roughness = 0.4;
mi.material.metalness = 0.0;
mi.material.update();
}
});
}
updateChildMaterials(child);
});
};
updateChildMaterials(treeEntity);
}, 200);
treeTemplate = treeEntity;
resolve();
});
asset.on('error', (err) => {
console.error('加载圣诞树模型失败:', err);
alert(`模型加载失败:${asset.file.url}\n错误:${err.message}`);
alert('树木模型加载失败,请检查文件路径和格式!');
reject(err);
});
app.assets.load(asset);
});
};
// ====================== 模型创建 ======================
const makeDog = (parent) => {
if (!dogTemplate) return;
const dogEntity = dogTemplate.clone();
dogEntity.setPosition(0, 0, 0);
parent.addChild(dogEntity);
};
const makeCar = (parent) => {
if (!carTemplateBlue) return;
const carEntity = carTemplateBlue.clone();
carEntity.setPosition(0, 0, 0);
parent.addChild(carEntity);
};
const makeCarBlack = (parent) => {
if (!carTemplateBlack) return;
const carEntity = carTemplateBlack.clone();
carEntity.setPosition(0, 0, 0);
parent.addChild(carEntity);
};
const makeTruck = (parent) => {
if (!truckTemplate) return;
const truckEntity = truckTemplate.clone();
truckEntity.setPosition(0, 2, 0); // 货车模型的第2个值之前为2,现在改模型后改为0,本条注释暂时不要删
parent.addChild(truckEntity);
};
const makePerson = (parent) => {
if (!personTemplate) return;
const personEntity = personTemplate.clone();
personEntity.setPosition(0, 0, 0);
parent.addChild(personEntity);
};
const makeBarrier = (parent) => {
if (!barrierTemplate) return;
const barrierEntity = barrierTemplate.clone();
barrierEntity.setPosition(0, 0, 0);
parent.addChild(barrierEntity);
};
// 新增:创建树木实例
const makeTree = (parent, position) => {
if (!treeTemplate) return null; // 返回null避免后续报错
const treeEntity = treeTemplate.clone();
treeEntity.setPosition(position.x, position.y, position.z);
parent.addChild(treeEntity);
return treeEntity;
};
// ====================== 场景创建(核心修改) ======================
/**
* 创建相机
*/
const createCamera = () => {
camera = new pc.Entity('camera');
camera.addComponent('camera', { clearColor: new pc.Color(0.8, 0.8, 0.9), fov: 75 });
updateCameraPosition();
app.root.addChild(camera);
};
/**
* 创建灯光
*/
const createLight = () => {
// 方向光
const sun = new pc.Entity();
sun.addComponent('light', {
type: 'directional',
color: new pc.Color(1, 1, 0.95),
intensity: 3,
castShadows: true,
shadowResolution: 2048,
shadowBias: 0.001,
shadowNormalBias: 0.1,
enabled: true
});
sun.setEulerAngles(50, 30, 0);
app.root.addChild(sun);
// 环境光
const ambient = new pc.Entity();
ambient.addComponent('light', {
type: 'ambient',
intensity: 1.2,
color: new pc.Color(1, 1, 1),
enabled: true
});
app.root.addChild(ambient);
};
/**
* 创建地面和道路(修复纹理拉伸 + 修复报错)
*/
const createGround = () => {
if (!app) return;
// 1. 草地
const outerGround = new pc.Entity('outer-ground');
outerGround.addComponent('model', { type: 'box' });
outerGround.setLocalScale(400, 0.1, ROAD_LENGTH);
outerGround.setPosition(0, -0.1, 0);
const outerMat = new pc.StandardMaterial();
if (grassDiffuseTexture) {
outerMat.diffuseMap = grassDiffuseTexture;
outerMat.normalMap = grassNormalTexture;
outerMat.roughnessMap = grassRoughTexture;
outerMat.diffuse = new pc.Color(1,1,1);
// ✅ 安全调用 tiling
if (outerMat.diffuseMapTiling) outerMat.diffuseMapTiling.set(40, 40);
if (outerMat.normalMapTiling) outerMat.normalMapTiling.set(40, 40);
if (outerMat.roughnessMapTiling) outerMat.roughnessMapTiling.set(40, 40);
} else {
outerMat.diffuse = GROUND_COLOR;
}
outerMat.roughness = 0.9;
outerMat.useLighting = true;
outerMat.update();
outerGround.model.material = outerMat;
app.root.addChild(outerGround);
// 2. 道路
const road = new pc.Entity('road');
road.addComponent('model', { type: 'box' });
road.setLocalScale(ROAD_WIDTH, 0.06, ROAD_LENGTH);
road.setPosition(0, -0.05, 0);
const roadMat = new pc.StandardMaterial();
if (asphaltDiffuseTexture) {
roadMat.diffuseMap = asphaltDiffuseTexture;
roadMat.roughnessMap = asphaltRoughTexture;
roadMat.diffuse = new pc.Color(1,1,1);
// ✅ 安全调用 tiling
if (roadMat.diffuseMapTiling) roadMat.diffuseMapTiling.set(6, 60);
if (roadMat.roughnessMapTiling) roadMat.roughnessMapTiling.set(6, 60);
} else {
roadMat.diffuse = ROAD_COLOR;
}
roadMat.roughness = 0.8;
roadMat.depthWrite = true;
roadMat.useLighting = true;
roadMat.update();
road.model.material = roadMat;
app.root.addChild(road);
// 3. 道路白线
const createRoadLine = (xOffset) => {
const line = new pc.Entity('road-line');
line.addComponent('model', { type: 'box' });
line.setLocalScale(0.2, 0.07, ROAD_LENGTH);
line.setPosition(xOffset, -0.04, 0);
const lineMat = new pc.StandardMaterial();
lineMat.diffuse = new pc.Color(1, 1, 1);
lineMat.roughness = 0.5;
lineMat.update();
line.model.material = lineMat;
app.root.addChild(line);
};
const halfRoadWidth = ROAD_WIDTH / 2;
createRoadLine(halfRoadWidth - 0.1);
createRoadLine(-halfRoadWidth + 0.1);
createRoadTrees();
};
/**
* 创建道路两侧的树木(支持滚动)
*/
const createRoadTrees = () => {
if (!app || !treeTemplate) return;
treeEntities.forEach(tree => {
try {
app.root.removeChild(tree);
tree.destroy();
} catch (e) {}
});
treeEntities = [];
const startZ = -ROAD_LENGTH;
const endZ = ROAD_LENGTH;
for (let z = startZ; z <= endZ; z += TREE_SPACING) {
const treeEntity = makeTree(app.root, { x: -TREE_OFFSET, y: 0, z });
if (treeEntity) {
treeEntity.originalZ = z;
treeEntities.push(treeEntity);
}
}
for (let z = startZ + TREE_SPACING/2; z <= endZ; z += TREE_SPACING) {
const treeEntity = makeTree(app.root, { x: TREE_OFFSET, y: 0, z });
if (treeEntity) {
treeEntity.originalZ = z;
treeEntities.push(treeEntity);
}
}
};
/**
* 新增:更新树木位置实现滚动效果
*/
const updateTreePositions = () => {
if (treeEntities.length === 0) return;
const halfLength = ROAD_LENGTH / 2;
treeEntities.forEach(tree => {
try {
const currentZ = tree.originalZ + treeOffsetZ;
let newZ = currentZ;
if (currentZ > halfLength) {
newZ = currentZ - ROAD_LENGTH * 2;
} else if (currentZ < -halfLength) {
newZ = currentZ + ROAD_LENGTH * 2;
}
tree.setPosition(tree.getPosition().x, tree.getPosition().y, newZ);
} catch (e) {}
});
};
/**
* 创建玩家
*/
const createPlayer = () => {
player = new pc.Entity('player');
makeDog(player);
player.setPosition(0, 0.1, 0);
app.root.addChild(player);
playerX.value = 0;
};
// ====================== 键盘控制 + 树木滚动 ======================
const listenKeyboard = () => {
if (!app) return;
let left = false, right = false;
const speed = 14;
app.keyboard.on('keydown', e => {
if (e.key === pc.KEY_LEFT) left = true;
if (e.key === pc.KEY_RIGHT) right = true;
});
app.keyboard.on('keyup', e => {
if (e.key === pc.KEY_LEFT) left = false;
if (e.key === pc.KEY_RIGHT) right = false;
});
app.on('update', dt => {
if (!player) return;
let playerPos = player.getPosition();
const halfRoadWidth = ROAD_WIDTH / 2;
if (left) playerPos.x = Math.max(-halfRoadWidth, playerPos.x - speed * dt);
if (right) playerPos.x = Math.min(halfRoadWidth, playerPos.x + speed * dt);
player.setPosition(playerPos);
playerX.value = playerPos.x;
treeOffsetZ += TREE_SCROLL_SPEED * dt;
updateTreePositions();
updateCameraPosition();
// 刷新障碍物位置
Array.from(obstacleEntities.entries()).forEach(([id, entity]) => {
const originalData = obstacleRawData.get(id) || {};
if (originalData.position) {
const rosX = originalData.position.x;
const rosY = originalData.position.z;
const rotatedX = rosY;
const rotatedZ = -rosX;
const relativeX = rotatedX - playerPos.x;
const relativeZ = rotatedZ - playerPos.z;
entity.setPosition(-relativeX, 0, relativeZ);
}
});
});
};
// ====================== 应用初始化 ======================
const initApp = async () => {
await nextTick();
if (!canvasContainer.value) return;
// 初始化PlayCanvas应用
app = new pc.Application(canvasContainer.value, {
elementInput: new pc.ElementInput(canvasContainer.value),
mouse: new pc.Mouse(canvasContainer.value),
keyboard: new pc.Keyboard(window),
touch: new pc.TouchDevice(canvasContainer.value),
graphicsDeviceOptions: {
webgl2: true,
antialias: true,
powerPreference: 'high-performance'
},
createCanvas: false
});
// 场景兜底初始化
if (!app.scene) app.scene = new pc.Scene(app.graphicsDevice);
app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
app.setCanvasResolution(pc.RESOLUTION_AUTO);
// 加载纹理
await loadTextures();
app.scene.background = new pc.Color(0.8, 0.8, 0.9);
// 加载模型
await Promise.all([
loadDogModel(),
loadCarModels(),
loadTruckModel(),
loadPersonModel(),
loadBarrierModel(),
loadTreeModel() // 加载树木模型
]);
// 创建场景元素
createCamera();
// 创建灯光
createLight();
createGround(); // 调用修改后的道路创建函数(包含树木)
createPlayer();
// 初始化交互
initMouseControls();
listenKeyboard();
// 初始化WebSocket
initObstacleWebSocket();
initRosImageWebSocket();
app.start();
// 启动经纬度模拟
startMapSimulation();
// 窗口自适应
window.addEventListener('resize', () => {
app?.resizeCanvas(window.innerWidth, window.innerHeight);
});
};
// 只更新经纬度 → 只让小地图动
function updateMapLatLng() {
// 循环取轨迹点
pointIndex = (pointIndex + 1) % latLngTrack.length;
const p = latLngTrack[pointIndex];
currentLat.value = p.lat;
currentLng.value = p.lng;
centerLat.value = p.lat;
centerLng.value = p.lng;
// console.log("updateMapLatLng:", centerLat.value, centerLng.value)
}
// 启动小地图模拟(安全版,不影响主游戏)
function startMapSimulation() {
if (mapTimer) clearInterval(mapTimer);
mapTimer = setInterval(updateMapLatLng, UPDATE_INTERVAL);
console.log("✅ 小地图经纬度模拟已启动");
// 定时器循环更新经纬度
// mapTimer = setInterval(() => {
// if (!latLngTrack || latLngTrack.length === 0) return;
// // 更新到下一个轨迹点
// pointIndex = (pointIndex + 1) % latLngTrack.length;
// const currentPoint = latLngTrack[pointIndex];
// // 更新当前经纬度(触发小地图组件更新)
// currentLat.value = currentPoint.lat;
// currentLng.value = currentPoint.lng;
// }, UPDATE_INTERVAL);
}
// 清理定时器
function stopMapSimulation() {
console.log("✅ 小地图经纬度模拟已清理定时器");
if (mapTimer) {
clearInterval(mapTimer);
mapTimer = null;
}
}
// ====================== 生命周期 ======================
onMounted(() => {
initApp();
});
onUnmounted(() => {
// 清理双击定时器
clearTimeout(clickTimer);
// 关闭WebSocket连接
if (obstacleWs) {
obstacleWs.close();
obstacleWs = null;
}
if (rosImageWs) {
rosImageWs.close();
rosImageWs = null;
}
// 释放图片URL
releaseAllImageUrls();
// 停止经纬度模拟定时器
stopMapSimulation();
// 清理图片元素
if (rosImage.value && rosImage.value.src) {
URL.revokeObjectURL(rosImage.value.src);
rosImage.value.src = '';
}
// 销毁PlayCanvas应用
if (app) {
app.stop();
app.destroy();
app = null;
}
// 清理障碍物
obstacleEntities.forEach(entity => {
app?.root.removeChild(entity);
entity.destroy();
});
obstacleEntities.clear();
obstacleRawData.clear();
// 清理树木
treeEntities.forEach(tree => {
app?.root.removeChild(tree);
tree.destroy();
});
treeEntities = [];
// 清理事件监听
const canvas = canvasContainer.value;
if (canvas) {
canvas.removeEventListener('mousedown', () => {});
canvas.removeEventListener('wheel', () => {});
canvas.removeEventListener('contextmenu', () => {});
canvas.removeEventListener('mouseout', () => {});
}
window.removeEventListener('mouseup', () => {});
window.removeEventListener('mousemove', () => {});
window.removeEventListener('resize', () => {});
});
</script>
<style scoped>
.game-container {
width: 100vw;
height: 100vh;
overflow: hidden;
position: relative;
}
#app-container {
width: 100%;
height: 100%;
display: block;
cursor: grab;
}
.game-info {
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(255,255,255,0.92);
padding: 10px 20px;
border-radius: 10px;
z-index: 10;
font-size: 17px;
text-align: center;
user-select: none;
}
.ros-image-panel {
position: absolute;
top: 3px;
left: 3px;
background: rgba(255,255,255,0.92);
padding: 10px;
border-radius: 10px;
z-index: 10;
}
.ros-image-panel h4 {
margin: 0 0 5px 0;
font-size: 14px;
text-align: center;
}
.ros-image {
width: 480px;
height: 320px;
object-fit: cover;
border-radius: 5px;
border: 1px solid #ccc;
transition: opacity 0.1s ease-in-out;
}
:deep(.leaflet-container) {
width: 320px;
height: 320px;
border-radius: 50%;
overflow: hidden;
}
</style>
六、踩坑实录------纹理拉伸问题
6.1、问题解决
纹理拉伸、模糊、变形、很难看,修复纹理拉伸,最小改动:
- 纹理设置为 重复模式(REPEAT)
- 给地面 / 道路加 tiling 平铺系数(让纹理重复很多次,不再拉伸)
js
// 缓存纹理引用
grassDiffuseTexture = grassDiffuseAsset.resource;
grassNormalTexture = grassNormalAsset.resource;
grassRoughTexture = grassRoughAsset.resource;
asphaltDiffuseTexture = asphaltDiffuseAsset.resource;
asphaltRoughTexture = asphaltRoughAsset.resource;
// ✅ 关键修复:开启纹理重复模式,解决拉伸
if (grassDiffuseTexture) {
grassDiffuseTexture.addressU = pc.ADDRESS_REPEAT;
grassDiffuseTexture.addressV = pc.ADDRESS_REPEAT;
}
if (grassNormalTexture) {
grassNormalTexture.addressU = pc.ADDRESS_REPEAT;
grassNormalTexture.addressV = pc.ADDRESS_REPEAT;
}
if (grassRoughTexture) {
grassRoughTexture.addressU = pc.ADDRESS_REPEAT;
grassRoughTexture.addressV = pc.ADDRESS_REPEAT;
}
if (asphaltDiffuseTexture) {
asphaltDiffuseTexture.addressU = pc.ADDRESS_REPEAT;
asphaltDiffuseTexture.addressV = pc.ADDRESS_REPEAT;
}
if (asphaltRoughTexture) {
asphaltRoughTexture.addressU = pc.ADDRESS_REPEAT;
asphaltRoughTexture.addressV = pc.ADDRESS_REPEAT;
}
6.2、效果对比
平铺效果:

拉伸效果:
