《超维新视界:把整座校园装进浏览器 》 基于Three.js+SSM+Springboot
在这两年的校园信息化折腾里,我做过不少"看着能用、其实就是个壳"的东西——后台管理系统、爬虫、课程设计。直到做完这个项目,才第一次有"这东西真的能给别人用"的感觉。
项目叫超维新视界,一句话说清:打开网页拖动鼠标,就能 360° 环视操场;点画面里的标签,一段实拍的行进镜头带你走过去;旁边还蹲着一个叫「咸师小匠」的 AI 助手,问它图书馆几点开门、机电学院在哪,都能答上来。
这是我在咸阳师范学院读本科期间主导的省级大学生创新创业训练计划项目,2025 年 9 月立项,2026 年 3 月交付,2026 年 4 月在《价值工程》发了论文(2026 年第 4 期,165–168 页)。代码已经按 MIT 开源了,仓库在 GitHub 上(有源码和演示视频):xs2580/hyper-vision-campus: 超维新视界 | 沉浸式校园全景与 AI 智能导览平台 | Three.js 全景渲染 + SpringBoot + Vue3 + RAG | 省级大创项目 | 论文见刊《价值工程》
下面不复述 README,挑几件真正费过劲的事说:全景怎么塞进浏览器、标签怎么知道该露脸、视频过渡为什么会让浏览器黑屏、大模型为什么一开始答不出人话。
一、先看它长什么样
首页很普通,轮播图、校园新闻、两个功能入口。右上角那只飘来飘去的小狮子就是 AI 助手的入口。

核心是下面这个全景页。图上中央浮着一个「中心广场」的标签,左下角实时显示当前视角角度——这个数字是后面所有逻辑的地基。

导航页更克制:起点只能选东门或西门,终点从起点的邻居里动态长出来,选完点「规划路线」,系统沿着场景图把路线一段段播成行进视频。

整体结构是这样的。**注意绿色的那条线:前端干重活,后端只负责把文件流出去、把表查出来。**这不是偷懒,是被预算逼的——整套系统跑在 2 核 4G 的云服务器上,把渲染、语音合成、图查询全放到浏览器里,服务器才扛得住。

二、为什么不做三维重建
做 VR 校园,第一反应基本都是"建模":无人机倾斜摄影、NeRF、三维重建,听起来才叫技术。
我们没走这条路,原因很实在——成本差一个数量级,效果还不一定更好。
| 三维重建 | 实拍全景 | |
|---|---|---|
| 设备 | 无人机 / 激光扫描仪 | 一台全景相机 |
| 周期 | 一个月起,还得后期 | 走了两天,拍完就能剪 |
| 真实度 | 建得再好也是"像" | 1:1,是真的 |
| 代价 | 手机端跑不动 | 只能停在拍摄点位 |
选了右边,就接受了那个代价:用户不能在两个点位之间自由走动。
补这个洞的办法是"用视频假装走路"——把两个点位之间的实拍行进镜头单独存成一段 MP4,点一下标签就播一遍。人眼对"走"的感知主要来自画面连续位移和边缘视差,一段 8 秒的第一人称视频足够骗过去。
所以整套系统在数据层面就变成了一个非常朴素的模型:**场景是节点,过渡视频是边。**后面所有花活都是从这个模型长出来的。
三、全景球:一张 2:1 的图怎么变成"天空"
全景图的本质是一张 2:1 的等距圆柱投影(equirectangular)。要做成沉浸式视角,思路很土:把它贴在球的内壁上,相机站在球心。
Three.js 里最省事的做法是建个球面几何,然后沿 X 轴翻转法线,让贴图朝里:
const geometry = new THREE.SphereGeometry(500, 60, 40)
geometry.scale(-1, 1, 1) // 关键:翻转法线,从球内看贴图
material = new THREE.MeshBasicMaterial({ color: 0xffffff })
sphere = new THREE.Mesh(geometry, material)
scene.add(sphere)
camera = new THREE.PerspectiveCamera(75, w / h, 0.1, 1000)
camera.position.set(1, -0.1, 0) // 站球心附近,留一点偏移避开奇异点
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true // 拖拽惯性,转起来才顺
三个坑,都是我们踩过才记住的:
**第一个,不翻法线。**贴图会贴在球的外表面,相机在球内看出去是一片纯黑。第一次跑起来黑屏的时候我以为是路径写错了,查了半小时贴图加载,最后发现是法线方向。
**第二个,材质不能用 MeshStandardMaterial。**第一版我下意识用了标准材质,觉得"标准"应该更好。结果是全景图本来就已经带了拍摄时的光照信息,再叠一层光照直接过曝,整个画面发白,而且白白浪费了 PBR 的计算。全景球只该用 MeshBasicMaterial——它只负责把像素铺上去,不参与光照。
第三个,异步加载会闪一帧黑。TextureLoader 是异步的,贴图没加载完就 render 会出黑帧。加载完成回调里补一句 material.needsUpdate = true 才行。
四、标签怎么知道该露脸
这是整个前端最费脑子的一块。
全景图里没有任何真实的几何体——没有墙、没有路、没有可以射线检测的东西。可交互标签的位置只能靠算:把相机的朝向换成角度,和数据库里存的方位角做减法。
每帧从相机取一次世界方向向量:
const dir = new THREE.Vector3()
camera.getWorldDirection(dir)
const yaw = Math.atan2(dir.x, -dir.z) * (180 / Math.PI) // 水平角,-180~180
const pitch = Math.asin(dir.y) * (180 / Math.PI) // 俯仰角,-90~90
scene_edge 表里存的 angle,是建场景时录进去的"这条路在画面里的方位"。前端拿到后和当前 yaw 做差:
let diff = Math.abs(yaw - edge.angle)
if (diff > 180) diff = 360 - diff // 处理 ±180 边界
hotspot.visible = diff <= 25 // 进入 ±25° 视野才显示
第一版是把角度写死在前端的。看起来能用,但只要换一个点位就全错——因为每个点位拍的朝向不一样。改了两天才想明白:**角度是数据,不是代码。**现在加一个新场景,只往 scene_edge 插一行,前端一个字都不用改。

**±25° 这个阈值是调出来的,不是算出来的。**设成 15°,用户想找出口得左右转半天;设成 40°,好几个标签会一起挤在画面中间,点哪个都点不准。
箭头还有个我觉得挺值的小细节:根据 pitch 用 CSS 3D 变换做一个"透视仰角"——抬头时箭头立起来,低头时趴下去,视觉上更像贴在地面上的路标。
<div class="tag-notice"
:style="{
opacity: cameraPitch < 30 ? Math.max(0, (30 - cameraPitch) / 30) : 0,
transform: `perspective(80px) rotateX(${-cameraPitch}deg)`
}">
五、整座校园就是一张图
数据库只有三张表,结构简单到有点寒酸,因为复杂度全在前端的图算法里:
image (id, url, description, create_time, update_time) -- 场景 = 节点
video (id, url, create_time, update_time) -- 过渡视频 = 边上的资产
scene_edge (start_id, end_id, video_id, angle, ...) -- 有向边
image.url 和 video.url 存的是素材文件的绝对路径,不是二进制。全景图单张 20MB 以上,进数据库是自找麻烦。
前端一次性拉全量边表,在内存里建成邻接表:

这里有个需要交代的点:**论文里把这个结构写成了"邻接矩阵",代码里其实是邻接表。**场景是稀疏图,一个点位通常只有 2~4 个出口,邻接表查邻居是 O(1),矩阵反而浪费。投稿的时候没改过来,写博客就按代码说。
这张表同时支撑三件事:当前场景有哪些出口(生成标签)、导航页的终点候选、点标签之后该播哪段视频。一个数据结构干三份活,很划算。
六、走过去的那十几秒
点标签之后,球面上的贴图要从静态图换成视频,播完再换回来。做法是把 <video> 当纹理源,实时贴到球上:
vid.addEventListener('canplay', () => {
videoTexture = new THREE.VideoTexture(vid)
videoTexture.minFilter = THREE.LinearFilter
videoTexture.magFilter = THREE.LinearFilter
material.map = videoTexture
material.needsUpdate = true
vid.play()
}, { once: true })
vid.onended = async () => {
await loadBackgroundImage(targetSceneId) // 换成目标场景的静态全景
buildHotspots() // 重建出口标签
}

四个不那么直观的处理,全是被坑出来的:
{ once: true }。canplay 在拖进度条时会重复触发,不加一次性监听就会重复创建 VideoTexture,显存直接爆。
**播放期间隐藏标签。**用 allowShowTag 开关控制,不然用户一边看行进镜头一边手贱点标签,状态就乱了。
**播放/暂停做 1.7 秒防抖。**视频解码需要时间,连着快点点会让 play() 和 pause() 打架。按钮先禁用 1.7 秒,等状态稳定再放开。
换场景必做的收尾三件套:
videoTexture.dispose()
vid.src = ''
vid.load()
**这三行是整个项目最贵的一课。**少做一件,每跳一次场景就漏一个解码器。表现是:刚打开一切正常,逛十几个场景之后浏览器突然黑屏、风扇狂转、标签全没。
这个 bug 定位了整整两天。因为它在开发时几乎复现不出来——我们测试总是跳三五个场景就回头改代码,只有真的"逛"上十几分钟才会撞上。最后是靠反复跳场景 + 看内存曲线才锁定到 VideoTexture 不会自动回收。
七、让页面开口说话
语音讲解用的是浏览器自带的 SpeechSynthesis,没上云端 TTS。理由很简单:一段几十秒的讲解,云端 TTS 的延迟和费用都不划算,而 Edge 自带的在线自然语音质量已经够了。
const utter = new SpeechSynthesisUtterance(text)
const yunxia = voices.find(v => v.name.includes('Microsoft Yunxia Online (Natural)'))
if (yunxia) utter.voice = yunxia // 优先选自然度最高的中文女声
utter.lang = 'zh-CN'
utter.rate = 1.0
window.speechSynthesis.speak(utter)
难点从来不在"读出来",而在和视频同步:
- 视频暂停 →
speechSynthesis.pause();视频继续 →resume(); - 用户点静音 → 直接
cancel(),并且把isSpeaking复位,声波动画停下来; - 语音自然播完 → 关掉声波动画,且不再让播放/暂停按钮反向控制它(
isControl = false)。
最后一条最容易漏。不处理的话,语音播完之后用户再点暂停,会发现"暂停按钮开始控制一段已经结束的语音",界面上那个声波会诡异地重新跳起来。
八、AI 助手:给大模型一本校园手册
通用大模型答"咸阳师范学院图书馆几点关门",十有八九是编的。解决办法是检索增强生成:先把校园资料整理成知识库,提问时先检索、再让模型基于检索结果回答。
第一版用的是 RAGFlow,后端 WebClient 调 /api/v1/chats/{chatId}/completions,以 SSE 形式透传给前端;前端没用 EventSource(它只支持 GET),改用 fetch + ReadableStream 手动解帧,每读到一个 data: 片段就更新消息,并用 requestAnimationFrame 节流渲染,避免高频 setState 把页面卡死。
效果是这样——能看出它是"先结论、再位置、再建议",而不是背书:

**但这一版有个尴尬的地方:项目结题后 RAGFlow 实例和 API Key 都回收了。**于是三个 Java 文件被整体注释掉,只保留实现参考(前端 AI 页面、接口封装、流式解析逻辑都还是可用的)。
为了让它真的能重新跑起来,我后来做了一次小升级:把 RAGFlow 换成 Spring AI + 百炼,整个链路收回自己手里。
8.1 依赖就两个,但版本必须对上
<!-- 百炼原生接入:提供 ChatModel + EmbeddingModel 自动装配 -->
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
<version>1.0.0.2</version>
</dependency>
<!-- RAG 顾问:不在上面 starter 的传递依赖里,必须显式加 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-advisors-vector-store</artifactId>
<version>1.0.0</version>
</dependency>
第二个会被人漏掉——QuestionAnswerAdvisor 不在 starter 的传递依赖里,不加就是编译找不到类。而且它的版本必须和 starter 解析出来的 Spring AI 版本一致(这里是 1.0.0 GA),不然运行时报 NoSuchMethodError,这种错最难查。
8.2 建向量库:落盘是关键
思路很直白:读 txt → 切片 → 调 text-embedding-v4 向量化 → 存 SimpleVectorStore → 落盘 JSON。
public SimpleVectorStore loadOrBuild() {
File f = storePath.toFile();
if (f.exists()) { // 已有索引:直接加载,0 费用
SimpleVectorStore store = SimpleVectorStore.builder(embeddingModel).build();
store.load(f);
log.info("[RAG] 加载已有索引 {}", f.getAbsolutePath());
return store;
}
return build();
}
public SimpleVectorStore build() {
Resource resource = resourceLoader.getResource(knowledgePath);
List<Document> docs = new TextReader(resource).get(); // 整个 txt 是一个 Document
List<Document> chunks = new TokenTextSplitter().apply(docs); // 默认 800 token/片
SimpleVectorStore store = SimpleVectorStore.builder(embeddingModel).build();
store.add(chunks); // 这一步在调 API,最慢
Files.createDirectories(storePath.getParent());
store.save(storePath.toFile());
return store;
}
**必须落盘,不落盘就是烧钱。**我们的知识库大概 1.4MB 中文,约 90 万 token,切完 1500~2500 个片段,首次构建大约 0.5 元。如果不落盘,每次重启都要重新 embed 一遍——按一天重启二十次算,一天十块钱,纯浪费。
store.add(chunks) 是逐条 HTTP 请求,串行跑要十几分钟。后来改成 8 条一批、4 线程并发 + 429 退避重试,才压到两分钟出头。百炼的 embedding 有 RPM 限制,并发拉满就等着收 429。
8.3 问答:两个 Advisor 把活干完
MessageWindowChatMemory memory = MessageWindowChatMemory.builder().maxMessages(20).build();
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("你是咸师小匠,咸阳师范学院校园助手……(写清回答范围、不要编造)")
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(memory).build(),
QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder()
.topK(topK)
.similarityThreshold(threshold)
.build())
.build())
.build();
一个管多轮记忆(留最近 20 条),一个管检索注入(取 topK=5,相似度低于 0.5 的直接丢)。写起来就五行,比手搓 RAG 舒服得多。
8.4 一个必须知道的隐藏契约
这篇里最容易出事的不是 RAG,是流式返回的格式。
当时前端 Homeview.vue 里对 data.answer 是赋值:
lastAnswer = answerText // 不是 += ,是 =
所以服务端每个 SSE 事件必须发**"到目前为止的完整回答"**,不能发增量 delta。发增量的话,前端每次都覆盖,屏幕上只会剩下最后一个字。
public Flux<String> streamChat(String sessionId, String question) {
StringBuilder acc = new StringBuilder();
return chatClient.prompt()
.user(question)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId))
.stream().content()
.map(delta -> {
acc.append(delta);
return "{\"data\":{\"answer\":" + jsonEscape(acc.toString()) + "}}";
})
.doOnComplete(() -> { /* 流结束再落会话历史 */ });
}
这个坑很隐蔽,因为语法上完全合法,只是显示不对。判断依据只有前端那行断言——写后端的人如果不去看前端怎么消费,一定会写成增量。
8.5 知识库不够,AI 就开始说"不知道"
升级完功能是通的,但第一次拿真实问题测就露馅了:问"计算机学院有哪些老师、分别教什么课",它答不出来。

注意它这里其实做得对——**没查到就说没查到,还告诉你该去学校官网或联系学院。**这比编一堆假名字强得多(也说明 system prompt 里"不要编造"那句起作用了)。
问题在数据侧:原来的知识库里只有学校概况、院系介绍、食堂这些"大颗粒"信息,没有任何教师维度的记录。后来把各学院教师的基本信息(任教课程、研究方向,不含任何联系方式、住址、身高体重这类隐私)补进 xync-campus-info.txt,重建索引,同一个问题就变成:

(图里的教师姓名我打码了,这篇是公开发布的,不该把别人的名字摆上去。)
这一步让我真正理解了 RAG 的边界:**检索增强只能让模型"说实话",不能让它"知道更多"。**知识库没有的东西,再强的模型也变不出来;而知识库有的东西,只要切分和召回没做好,照样答不出来。
九、踩过的坑
挑几个印象最深的,按"当时有多懵"排序:
| 现象 | 原因 | 解法 |
|---|---|---|
| 全景一片纯黑 | 球体法线没翻,贴图在外表面 | geometry.scale(-1, 1, 1) |
| 画面过曝发白 | 用了 MeshStandardMaterial 又叠光照 | 换 MeshBasicMaterial |
| 换场景后标签全错位 | 角度写死在前端 | 角度存库,运行时算 yaw 差值 |
| 逛十几分钟后黑屏 | VideoTexture 没回收,解码器泄漏 | dispose() + 清 src + load() |
| 语音播完后按钮错乱 | isSpeaking 没复位 | 播完置 isControl = false |
启动就 DuplicateKeyException | yml 里写了两个顶层 spring: | 合并成一个 |
SimpleVectorStore 构造器不可见 | 1.0.0 GA 改成了 builder | SimpleVectorStore.builder(model).build() |
| 换 embedding 模型后启动报维度不匹配 | 旧 JSON 的向量维度对不上 | 删 data/vectorstore.json 重建 |
| AI 每个字都被覆盖,只剩最后一个 | SSE 发的是增量而不是累积全文 | 每个事件发累积全文 |
对话 403 Free quota exhausted | 百炼控制台"仅用免费额度"没关 | 去控制台关掉 |
还有一条没进表格但我觉得最该说:**API Key 从头到尾没写进 application.yml。**那个文件是会进 Git 的。
spring:
ai:
dashscope:
api-key: ${DASHSCOPE_API_KEY:} # 只读环境变量,别写死
${DASHSCOPE_API_KEY:} 这个写法的意思是"从环境变量取,取不到就给空字符串"——它本身不配置任何东西。所以如果环境变量没设,启动不报错,但一调模型就 401。第一次遇到这个的时候我盯着 yml 看了很久,以为占位符写错了。
十、最后
项目做完拿了几个结果,算是给团队一个交代:省级立项(S202510722050),入选学校第十五批校园创业孵化基地(计算机学院当批唯一入选,5000 元资助),论文《基于 Three.js 的超维新视界平台设计与实现》发在《价值工程》2026 年第 4 期。


更多推荐



所有评论(0)