
简介这套720°VR全景源码系统面向想快速搭建全景展示平台的开发者、站长与中小型团队提供从全景制作、上传到网页端交互浏览的完整实现思路并已做完整性与可用性测试。压缩包共1946个文件以PHP程序、PNG/GIF图像、JS脚本、XML配置及LBI等素材为主并含少量批处理与服务器配置文件整体约104.53MB可覆盖多服务器部署、访问控制、错误页、用户认证、二维码分享、短信验证与资源下载等常见环节。已有7266人学习下载说明其在全景建站场景中具备一定参考热度。内容还包含全景图批处理制作工具与多分辨率、立方体/球面转换等操作入口便于读者理解全景项目从素材加工到前台环视交互的流程适合作为搭建云全景站点、研究krpano类交互方案及二次开发的基础代码包。1. 一套 720全景源码到底交付了什么拆开压缩包后先看这三层很多人拿到 720全景源码第一反应是双击入口文件看到球体转起来就认为“跑通了”。真动手改才发现全景站和普通图片站是两套逻辑等距柱状图宽高比必须固定在 2:1相机初始视角差一个参数就出现拉伸黑边热点坐标写死之后换个场景就整体漂移。这套最新 720 全景源码系统实质是一个可以自行部署的云全景站点骨架——前端负责球面渲染和交互后端管理素材、场景、热点与多项目全景制作那一层提供的是切片和发布的流水线。它适合手里已经有一批全景素材、需要自己搭一个改得动样式、接得上业务的全景展示站的人不适合只想套模板换张皮的人因为真正的坑全埋在参数和坐标里。2. 全景渲染的几何前提2:1 等距柱状图与球面相机的对应关系2.1 等距柱状图为什么严格锁死 2:1全景素材不是随手拍张照片就能用的。720 全景的原始素材标准形态是等距柱状投影图Equirectangular Projection它把整个球面沿着经度和纬度展开成一张矩形横向 360 度、纵向 180 度。因为经度范围是纬度的两倍所以宽高比必须严格是 2:1只要偏离这个比例球面映射就会横向挤压或纵向拉伸表现出来就是画面被压扁或者两极出现奇怪的旋涡状畸变。这一点在源码里通常不会写死在渲染逻辑里而是放在上传校验上。很多现成的包只在文档里提一句“请上传 2:1 图片”代码里根本没有拦结果运营同学传了一张 16:9 的图进去前端照样渲染用户看到的是一个被拉长的世界。合格的实现应该在服务端接收文件时就做一次尺寸校验不通过直接拒收而不是等到前端出问题再回头找素材。另一个常被忽略的点是极点畸变。等距柱状图的两极也就是图片的最上一行和最下一行是被极度拉伸的越靠近极点的像素单位面积覆盖的球面角度越大。所以全景图的天空和地面部分即便原图看起来模糊投到球面上反而看不出问题反过来如果你把重要的文字标识放在图片顶部投上去会被拉成一条横线。经验做法是把视觉重心放在图片纵向中间 1/3 的带状区域内这个区间接近赤道畸变最小。纹理尺寸也有上限。常见素材是 4096×2048 或 8192×4096后者在移动端直接加载会非常吃显存尤其是在同时预加载多个场景的情况下。我的习惯是原图保留 8192 作为切片母版前端实际渲染按设备能力降级桌面端取 4096、移动端取 2048这个降级逻辑放在切片产物的不同层级里让前端按需选层而不是让浏览器自己去缩放大图。2.2 渲染器选型three.js、全景播放库和自研的边界源码包里前端用什么渲染直接决定了后面改功能的难度。市面上常见的三档实现边界很清楚方案典型实现优点局限原生 WebGL 手写自己写 shader 和球体网格体积极小可控性最高交互、惯性、陀螺仪全要自己补改一次成本很高three.js 直接搭SphereGeometry TextureLoader生态成熟加热点、加模型都好扩展需要自己处理纹理色彩空间、相机阻尼、销毁时机全景播放器库封装好的球面播放组件开箱即用API 简单深度定制受限遇到特殊交互要改源码云全景站点源码这类需要长期运营的项目我一般倾向 three.js 这一档。原因是全景站迟早要加东西热点标记、场景间转场、嵌入视频、陀螺仪跟随、VR 手柄进入。播放器库在“只做展示”阶段很舒服一旦要加业务逻辑就开始别扭。而 three.js 的代价只是多写几十行初始化代码换来的是后面所有扩展都不受制于人。选型时还要看源码里有没有做资源释放。全景站是典型的多场景切换应用每次切场景如果只是新建 mesh 而旧纹理不 dispose移动端浏览器跑十几个场景就会直接崩掉。这个细节在演示阶段完全看不出来上线之后才集中爆发。2.3 在源码里定位渲染入口与关键参数拿到包先别急着改样式先找到渲染入口。常见结构是src/pano/或static/js/viewer.js下有一个初始化函数参数集中在一个 config 对象里。找到它后面所有调试都从这里下手// 全景渲染初始化参数集中管理避免散落在各处 const PANO_CONFIG { radius: 500, // 球体半径需大于相机 far 的一半以下才不会被裁 segments: 60, // 经向分段低于 40 会出现明显多边形棱边 fov: 75, // 初始视场角手机端建议 80~90 减少压迫感 damping: 0.08, // 惯性阻尼越小越滑0.05 以下容易晕 minFov: 45, // 缩放上限太小会因纹理分辨率不足而糊 maxFov: 100 // 缩放下限太大会看到球体接缝 }; const scene new THREE.Scene(); const camera new THREE.PerspectiveCamera( PANO_CONFIG.fov, window.innerWidth / window.innerHeight, 0.1, 1100 // far 必须显著大于 radius ); const geometry new THREE.SphereGeometry( PANO_CONFIG.radius, PANO_CONFIG.segments, 40 ); geometry.scale(-1, 1, 1); // 法线向内翻转否则只能看到球的背面 const texture new THREE.TextureLoader().load(/pano/scene-01/equirect.jpg); texture.colorSpace THREE.SRGBColorSpace; // 缺这一行整张图会发灰发暗 texture.minFilter THREE.LinearFilter; // 避免非 2 的幂纹理生成多余 mipmap const material new THREE.MeshBasicMaterial({ map: texture }); scene.add(new THREE.Mesh(geometry, material));这段代码里radius和相机far是一对必须同时看的参数。球半径 500、far 1100相机在球心附近移动时球面始终在远裁剪面之内不会出现“转个头世界就缺一块”的情况。如果只改半径不改 far或者反着改画面边缘就会被裁掉而且现象很隐蔽——只有特定角度才露馅。geometry.scale(-1, 1, 1)这行是另一个高频翻车点。WebGL 默认只渲染正面球体法线朝外相机在球心看到的是所有三角形的背面结果就是一片纯色。翻转之后法线朝内纹理才能正常贴在视野里。有人用material.side THREE.BackSide替代效果等价但翻转几何体更省一次材质状态切换。colorSpace那行是血泪经验。不设的话纹理按线性空间解释整张全景图会明显偏暗、饱和度下降尤其在天空渐变区域特别明显很多人误以为是原图质量问题其实是色彩空间没配对。加上这一行画面立刻回到正常观感。3. 素材进站的流水线上传校验、多级切片与首屏缓存3.1 上传接口该拦哪几件事全景素材从后台上传到真正能展示中间隔着好几道工序。上传接口是整个流水线的第一道闸门它拦不住的东西后面全要返工。我一般会在这一层强制做四件事第一格式白名单。只收 jpg 和 pngwebp 虽然体积小但部分老设备的解码支持不稳定作为母版素材不合适。第二尺寸与比例校验宽高比必须落在 2:1 的容差范围内比如 ±1%宽高比不对的直接拒绝并给出明确提示不要静默接受。第三清理 EXIF。手机拍的全景图带着一堆方向、GPS、设备的元信息既增加体积又可能造成旋转异常统一剥掉。第四生成稳定的内部文件名不要用原始文件名入库中文名和空格在后续切片、CDN 回源时都是麻烦制造者。这一层还有一个容易被跳过的动作母版留档。切片产物是按层级生成的如果母版被覆盖就再也回不到高分辨率重新切了。我一般会把原始文件单独存一份到只读目录命名用场景 ID 加时间戳切片流程只读这份母版不做任何写操作。3.2 多级 LOD 切片为什么不能直接甩一张大图720 全景图动辄几 MB 到几十 MB直接扔给浏览器首屏就是一段长时间的白屏加一个转圈。更要命的是全景浏览是“随时可能看向任意方向”的交互用户视线落在哪一块那块就该是清晰的其余部分可以暂时用低清顶上。这就是多级 LOD细节层次切片存在的理由。常见做法是把母版按几个分辨率层级重新采样每一层再切成固定尺寸的瓦片比如 512×512 一块按层级/行/列的目录结构存放。前端先加载最低层级的少数几块铺满视野再随着视角停留时间逐步替换成更高层级的瓦片。这套机制在源码里通常藏在pano/slice或tools/目录下如果没有现成脚本可以按下面的逻辑补一个from PIL import Image import os # 层级从低到高每层宽高必须严格保持 2:1 LEVELS [(1024, 512), (2048, 1024), (4096, 2048), (8192, 4096)] TILE 512 def slice_panorama(src_path, out_dir): img Image.open(src_path) ratio img.width / img.height if abs(ratio - 2.0) 0.02: raise ValueError(f等距柱状图宽高比必须为 2:1当前 {img.width}x{img.height}) for level, (w, h) in enumerate(LEVELS): if w img.width: # 母版不够大就跳过更高层级避免强行放大 break lv_img img.resize((w, h), Image.LANCZOS) cols, rows w // TILE, h // TILE for r in range(rows): row_dir os.path.join(out_dir, str(level), str(r)) os.makedirs(row_dir, exist_okTrue) for c in range(cols): box (c * TILE, r * TILE, (c 1) * TILE, (r 1) * TILE) tile lv_img.crop(box) # quality 控制在 82 左右再高对观感提升很小但体积涨得快 tile.save(os.path.join(row_dir, f{c}.jpg), quality82, optimizeTrue) print(f切片完成{out_dir})代码里LEVELS的顺序是从低清到高清前端加载时也是先请求0层逐步往上升。if w img.width: break这句是保护母版只有 4096 宽的时候硬切 8192 层等于把图放大一倍再切除了增加体积没有任何意义反而让用户多等下载。quality82是个经验值。全景图本身细节密集压到 70 以下天空渐变区域会出现明显色带压到 90 以上体积几乎翻倍但肉眼分辨不出差别。82 这个档位在大部分全景素材上能兼顾观感和传输。切片产物的体积加起来会显著大于原图这是正常的因为低层级是冗余的。所以存放位置要规划好母版走低频存储切片产物走 CDN两者不要混在一个目录里否则备份和清理都会很难受。3.3 首屏加载顺序与缓存头配置切片做完只是有了素材用户能不能快速看到画面取决于加载顺序。一个稳妥的加载序列是先请求最低层级的整层或者按初始视角算出的可见瓦片集合渲染出可交互的画面然后空闲时预取下一层。用户在这段时间里已经能拖动视角了感知上就不存在白屏。缓存策略决定了第二次访问的体验。切片文件名里通常带层级和行列号内容一旦生成就不再变化属于典型的强缓存对象。在 nginx 里按目录做规则是最省事的# 切片产物内容不变长缓存 location /pano/tiles/ { expires 30d; add_header Cache-Control public, immutable; access_log off; } # 母版与上传目录不对公网暴露只允许内部访问 location /pano/master/ { deny all; }immutable这个指令值得加上它告诉浏览器在有效期内连条件请求都不用发直接走本地缓存。代价是文件名必须配合内容变化如果某个场景的切片被重新生成路径得跟着变否则用户会一直看到旧图。常见做法是把场景 ID 加上一个版本号拼进路径重新切片时版本号递增老路径自然失效。access_log off是为了减少日志量。全景站的切片请求数量很可观一个场景切下来几百上千个瓦片全量记录日志磁盘很快就会被填满而且这些日志的排查价值很低。4. 云全景站点的后台落地表结构、部署与热点配置4.1 项目、场景、热点三张核心表云全景网站源码的数据模型抽象下来就三层一个项目project对应一个独立的展示站一个项目下有多个场景scene每个场景上有若干热点hotspot。这个结构看起来简单但字段设计上踩过不少坑尤其是坐标和跳转关系。CREATE TABLE pano_project ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(64) NOT NULL, domain VARCHAR(128) DEFAULT NULL, -- 独立域名绑定可为空 theme_json TEXT, -- 主题配置整体存 JSON避免频繁改表 status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE pano_scene ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, project_id INT UNSIGNED NOT NULL, title VARCHAR(128) NOT NULL, master_path VARCHAR(255) NOT NULL, -- 母版相对路径 tile_path VARCHAR(255) NOT NULL, -- 切片根目录 tile_version INT UNSIGNED NOT NULL DEFAULT 1, -- 重新切片时递增用于破缓存 init_yaw DECIMAL(8,4) NOT NULL DEFAULT 0, -- 初始水平朝向单位度 init_pitch DECIMAL(8,4) NOT NULL DEFAULT 0, -- 初始俯仰-90~90 init_fov DECIMAL(6,2) NOT NULL DEFAULT 75, sort_order INT NOT NULL DEFAULT 0, KEY idx_project (project_id, sort_order) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE pano_hotspot ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, scene_id INT UNSIGNED NOT NULL, type TINYINT NOT NULL, -- 1 场景跳转 2 图文 3 外链 yaw DECIMAL(8,4) NOT NULL, -- 球面坐标不是屏幕坐标 pitch DECIMAL(8,4) NOT NULL, target_scene INT UNSIGNED DEFAULT NULL, -- type1 时指向目标场景 content_json TEXT, -- 图文气泡的内容 KEY idx_scene (scene_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;tile_version这个字段看着不起眼但它是解决缓存问题最省事的手段。切片产物的缓存头设得很长重新生成之后如果路径不变用户会一直拿着旧瓦片。有了版本号前端请求路径拼成/pano/tiles/{scene_id}/{tile_version}/...重新切片时把版本递增旧路径的缓存自然被绕过也不需要去 CDN 手动刷新。热点的yaw、pitch存的是球面坐标角度不是屏幕像素坐标。这是新手最容易搞错的地方在编辑器里点出来的位置如果按屏幕坐标存换个分辨率就完全对不上。球面坐标是跟视角无关的无论屏幕多大、初始朝向如何同一个角度永远指向球面上同一个点。4.2 运行环境与部署顺序部署顺序错了排查成本会翻倍。我的习惯是按“依赖 → 数据 → 静态资源 → 应用”这个顺序走每一步都能单独验证# 1. 环境检查确认 PHP/Node 版本与扩展满足源码要求 php -v php -m | grep -E gd|pdo_mysql|fileinfo # gd 用于缩略图fileinfo 用于上传校验 # 2. 建库并导入表结构 mysql -u root -p pano_db sql/schema.sql # 3. 目录权限上传目录和切片目录必须可写其余保持只读 chown -R www-data:www-data storage/upload storage/tiles chmod -R 755 storage/upload storage/tiles # 4. 写入站点配置后重载 nginx -t nginx -s reloadfileinfo扩展这一条要特别确认。上传校验如果用$_FILES[type]判断那是客户端传上来的值随便改一个就能绕过正确姿势是用finfo_file()读真实 MIME这依赖fileinfo扩展没开就会静默失败上传接口变成不设防。目录权限那条storage下面只给upload和tiles可写其余目录保持只读。全景站被上传漏洞打过的不在少数而权限收窄是最低成本的一道防线——即便上传接口被绕过写进了脚本脚本所在的目录没有执行权限也跑不起来。4.3 热点与场景跳转的参数含义热点配置里最容易出问题的是坐标转换。编辑时用户看到的是二维屏幕存储要的是三维方向中间得有一次投影逆运算。常见实现是监听点击位置的归一化坐标NDC再用射线与球面求交拿到交点后反算球面角度// 点击屏幕位置 → 球面坐标存库时用这个结果 const raycaster new THREE.Raycaster(); const pointer new THREE.Vector2(); function screenToSphere(event, camera, mesh) { const rect renderer.domElement.getBoundingClientRect(); // 归一化到 -1 ~ 1注意 y 轴要取反 pointer.x ((event.clientX - rect.left) / rect.width) * 2 - 1; pointer.y -((event.clientY - rect.top) / rect.height) * 2 1; raycaster.setFromCamera(pointer, camera); const hit raycaster.intersectObject(mesh)[0]; if (!hit) return null; const p hit.point; return { yaw: Math.atan2(p.x, -p.z) * 180 / Math.PI, // 水平角绕 y 轴 pitch: Math.asin(p.y / Math.hypot(p.x, p.y, p.z)) * 180 / Math.PI }; }yaw用atan2(x, -z)而不是atan2(z, x)是因为全景相机的默认朝向通常对着 -z 轴坐标系约定不同会导致算出来的角度整体偏 90 度表现出来的现象是“点了左边热点跑到右边”。这个错误我在不同源码包里见过好几次本质是坐标约定没对齐改的时候要么统一相机朝向要么在转换里补偏移量不要两边各改一半。pitch用asin(y / r)取值范围自然落在 -90 到 90 之间不需要额外夹紧。但如果球面交点算出来y略大于半径浮点误差asin会返回NaN热点的坐标就变成空值。稳妥的做法是在除法之后做一次 clamp把比值限制在 -1 到 1 之间。场景跳转还有一层状态衔接从 A 场景跳到 B 场景时用户在新场景里的初始朝向应该是什么如果直接按 B 的配置重置用户会有明显的“被拽走”感。常见做法是按跳转方向反推从 A 的某个角度离开进 B 时朝向 A 来的方向也就是 yaw 加 180 度这样视线是连贯的。这个逻辑通常在源码的jumpToScene函数里如果没有值得自己补上。5. 避坑与排查五个让全景站当场失守的真实故障5.1 渲染侧的三个坑现象全景图能加载但整个画面是纯黑或者一片单色。原因通常是球体法线方向反了相机在球心只看到三角形的背面被背面剔除掉了。另一种可能是纹理加载失败但没走 error 回调材质用了默认白色在没打光的情况下看起来接近黑色。 解决先确认几何体有没有做scale(-1, 1, 1)或材质有没有设side: THREE.BackSide再打开浏览器网络面板看纹理请求的返回状态404 和 403 都会导致这个现象。现象画面能看但整体偏暗发灰像蒙了一层。原因基本可以锁定在纹理色彩空间。three.js 从某个版本开始默认按线性空间解释纹理全景图是 sRGB 编码的不声明就会偏暗天空和阴影区域最明显。 解决加载纹理后设置texture.colorSpace THREE.SRGBColorSpace。顺便检查渲染器的输出色彩空间是不是也配成 sRGB两处要一致只改一处仍然会有色差。现象切换十几个场景后移动端页面直接白屏或崩溃。原因是每次切场景都新建几何体和纹理旧的没有释放显存越吃越多最后被系统杀掉。桌面端内存宽裕看不出来移动端很快撑不住。 解决在切换逻辑里显式调用geometry.dispose()和texture.dispose()材质也要dispose()并且把旧的 mesh 从 scene 里 remove。建议把“创建和销毁”封装成配对的方法避免漏掉某一步。5.2 服务端的两个坑现象运营传了一张看起来很正常的图前端渲染出来却整个变形。原因是上传接口没做宽高比校验素材本身不是 2:1可能是相机直出的 16:9 或者裁切过的图。前端不做二次判断照单全收结果就是拉伸。 解决在上传接口里读真实尺寸比例偏差超过 2% 直接拒收并提示。不要指望运营记住规则用代码拦住比写在文档里有效得多。现象重新切了图用户反馈还是旧画面清缓存才正常。原因是切片路径固定而缓存头设了长有效期浏览器和 CDN 都还拿着旧瓦片。手动刷 CDN 能临时解决但每次切片都要刷一遍迟早会漏。 解决引入版本号并拼进路径重新切片时版本递增新路径自然绕过所有旧缓存。这一步在建表的时候就该留字段后补要改前端、后端、CDN 三处成本高得多。6. 进阶把全景站做成可复用的交付模板6.1 用配置驱动替代散落的硬编码一套 720 全景源码跑通之后真正的价值不在于这一个站而在于它能不能变成下一个项目的起点。我的做法是把所有跟具体项目相关的东西抽到一个配置层场景列表、初始视角、主题色、热点数据全部走接口前端只保留一套渲染逻辑。这样做的直接好处是新项目交付时不用碰前端代码只换配置和数据。间接好处是排查问题时边界清晰——画面不对先确认是配置错了还是渲染错了两边分开看不用在一堆硬编码里翻。// 统一入口所有场景数据从接口取渲染层不感知业务 async function bootstrap(projectKey) { const res await fetch(/api/project/${projectKey}/scenes); const { scenes, theme } await res.json(); applyTheme(theme); // 主题变量注入 CSS const viewer createViewer(); // 渲染器只创建一次场景切换复用 await viewer.loadScene(scenes[0]); // 首屏只加载第一个场景 preloadRest(scenes.slice(1), viewer); // 其余场景空闲预取低层级 }createViewer()只调一次这点很关键。很多实现是在切场景时重建整个渲染器上下文反复创建销毁在移动端很容易触发 WebGL 上下文丢失。复用同一个渲染器只换纹理和场景数据稳定性会好很多。6.2 上线前我固定要跑的验收清单每套全景站交付前我会按固定顺序过一遍这几项覆盖了绝大多数线上问题检查项判断标准常见失败原因素材比例所有母版宽高比在 2:1 ±2%上传校验缺失首屏时间4G 网络下 3 秒内可交互直接加载高层级瓦片场景切换连续切 20 次无内存增长纹理和几何体未释放热点定位不同分辨率下位置一致存了屏幕坐标而非球面角缓存生效二次访问瓦片走 304 或本地缓存头缺失或路径未带版本这份清单不复杂但每一项背后都对应过一次真实的翻车。尤其是场景切换那一项用浏览器的内存快照对比切换前后的占用增长曲线是平的才算过。肉眼看着不卡不代表没泄漏等到用户反馈白屏就晚了。从那以后我每次接一套新的全景源码都强制先用一张自制的 2:1 测试图跑通「切片 → 渲染 → 热点打点」这三步再动任何业务代码。测试图上有网格和方向标记哪一步错了立刻能看出来比拿真实素材试错快得多。希望帮到你。本文还有配套的精品资源点击获取